use std::time::Duration;
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
use crate::poller::SourceEvent;
pub const DEFAULT_BASE_URL: &str = "https://api.alphai.io";
pub const SITE_URL: &str = "https://alphai.io";
pub const CACHE_TTL: Duration = Duration::from_secs(300);
pub const PAGE_SIZE: u8 = 20;
pub const MARKET_KEY: &str = "*";
pub const TRENDING_KEY: &str = "~trending";
pub const ARCHIVE_GATE_MSG: &str = "plan archive limit · upgrade: alphai.io/pricing";
pub fn is_archive_gate(error: &str) -> bool {
error.contains("archive limit")
}
pub fn is_unknown_symbol(error: &str) -> bool {
error.contains("API 404")
}
pub struct Client {
http: reqwest::Client,
base: String,
key: String,
}
impl Client {
pub fn new(key: String) -> Result<Self> {
let base = std::env::var("ALPHAI_API_URL")
.ok()
.filter(|u| !u.trim().is_empty())
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
let http = reqwest::Client::builder()
.user_agent(concat!("alphai-tui/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(15))
.build()?;
Ok(Self { http, base, key })
}
async fn get_json<T: DeserializeOwned>(&self, path: &str, query: &[(&str, &str)]) -> Result<T> {
let resp = self
.http
.get(format!("{}{}", self.base, path))
.bearer_auth(&self.key)
.query(query)
.send()
.await
.context("request failed")?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
bail!("invalid AlphAI API key, press s to update it (free keys: alphai.io)");
}
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let wait = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.unwrap_or("60");
bail!("AlphAI rate limit hit, retry in {wait}s (Free tier: 20/min, 100/day)");
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
let parsed = serde_json::from_str::<ErrorBody>(&body).ok();
if parsed
.as_ref()
.and_then(|e| e.extra.as_ref())
.and_then(|x| x.reason.as_deref())
== Some("archive_horizon")
{
bail!("{ARCHIVE_GATE_MSG}");
}
let msg = parsed
.and_then(|e| e.message.or(e.detail).or(e.error))
.unwrap_or_else(|| body.chars().take(120).collect());
bail!("AlphAI API {status}: {msg}");
}
resp.json().await.context("bad JSON from AlphAI")
}
pub async fn news(
&self,
symbol: Option<&str>,
cursor: Option<&str>,
page_size: Option<u8>,
min_relevance: Option<u8>,
sort: Sort,
) -> Result<NewsPage> {
let mut query: Vec<(&str, &str)> = Vec::new();
match symbol {
Some(s) => query.push(("symbol", s)),
None => query.push(("collapse", "story")),
}
if let Some(c) = cursor {
query.push(("cursor", c));
}
let size = page_size.map(|n| n.to_string());
if let Some(size) = &size {
query.push(("page_size", size));
}
let min = min_relevance.map(|n| n.to_string());
if let Some(min) = &min {
query.push(("min_relevance", min));
}
if let Some(order) = sort.param() {
query.push(("sort", order));
}
self.get_json("/api/news/", &query).await
}
pub async fn trending(&self) -> Result<Vec<Article>> {
self.get_json("/api/news/trending/", &[]).await
}
pub async fn insider_news(
&self,
symbol: &str,
cursor: Option<&str>,
page_size: Option<u8>,
min_relevance: Option<u8>,
sort: Sort,
) -> Result<NewsPage> {
let mut query: Vec<(&str, &str)> = vec![("symbol", symbol)];
if let Some(c) = cursor {
query.push(("cursor", c));
}
let size = page_size.map(|n| n.to_string());
if let Some(size) = &size {
query.push(("page_size", size));
}
let min = min_relevance.map(|n| n.to_string());
if let Some(min) = &min {
query.push(("min_relevance", min));
}
if let Some(order) = sort.param() {
query.push(("sort", order));
}
self.get_json("/api/news/insider/", &query).await
}
pub async fn sentiment(&self, ticker: &str) -> Result<SentimentSummary> {
self.get_json(&format!("/api/symbols/{ticker}/sentiment-summary/"), &[])
.await
}
pub async fn insider_trades(&self, ticker: &str) -> Result<InsiderTrades> {
self.get_json(
&format!("/api/symbols/{ticker}/insider-trades/"),
&[("page_size", "1")],
)
.await
}
pub async fn earnings(&self, ticker: &str) -> Result<TickerEarnings> {
self.get_json(&format!("/api/symbols/{ticker}/earnings/"), &[])
.await
}
pub async fn calendar(&self, from: &str, to: &str) -> Result<Vec<CalendarEvent>> {
let page: CalendarEvents = self
.get_json("/api/calendar/", &[("from_date", from), ("to_date", to)])
.await?;
Ok(page.events)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Sort {
#[default]
Published,
Ingested,
}
impl Sort {
fn param(self) -> Option<&'static str> {
match self {
Self::Published => None,
Self::Ingested => Some("ingested"),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FeedMode {
Replace,
Append,
Merge,
}
fn feed_mode(sort: Sort, cursor: Option<&str>) -> FeedMode {
match (sort, cursor) {
(Sort::Ingested, _) => FeedMode::Merge,
(Sort::Published, Some(_)) => FeedMode::Append,
(Sort::Published, None) => FeedMode::Replace,
}
}
pub enum Cmd {
SetKey(Option<String>),
FetchNews {
symbol: Option<String>,
cursor: Option<String>,
min_relevance: Option<u8>,
sort: Sort,
},
FetchTrending,
FetchInsider {
symbol: String,
cursor: Option<String>,
min_relevance: Option<u8>,
sort: Sort,
},
FetchEarnings { symbol: String },
FetchCalendar { from: String, to: String },
}
pub enum Event {
Feed {
key: String,
articles: Vec<Article>,
side: Option<FeedPayload>,
next_cursor: Option<String>,
mode: FeedMode,
min_relevance: Option<u8>,
},
PageError {
key: String,
error: String,
gated: bool,
},
PollError { key: String, error: String },
PollReprime { key: String },
Earnings {
key: String,
data: Box<TickerEarnings>,
},
Calendar { events: Vec<CalendarEvent> },
Error { key: String, error: String },
}
pub enum FeedPayload {
Sentiment(SentimentSummary),
Insider(Box<InsiderTrades>),
}
pub fn news_key(symbol: Option<&str>) -> String {
symbol.map_or_else(|| MARKET_KEY.to_string(), str::to_string)
}
pub fn insider_key(symbol: &str) -> String {
format!("ins:{symbol}")
}
pub fn earnings_key(symbol: &str) -> String {
format!("earn:{symbol}")
}
pub const CALENDAR_KEY: &str = "~calendar";
pub const CALENDAR_DAYS: i64 = 45;
enum Feed<'a> {
News(Option<&'a str>, Option<u8>),
Insider(&'a str, Option<u8>),
}
async fn fetch_feed_page(
client: &Client,
feed: Feed<'_>,
cursor: Option<&str>,
sort: Sort,
page50: &mut Option<bool>,
) -> Result<NewsPage> {
let try50 = matches!(*page50, Some(true))
|| (page50.is_none() && cursor.is_some() && sort == Sort::Published);
if try50 {
let result = match &feed {
Feed::News(symbol, min) => client.news(*symbol, cursor, Some(50), *min, sort).await,
Feed::Insider(symbol, min) => {
client
.insider_news(symbol, cursor, Some(50), *min, sort)
.await
}
};
match result {
Ok(page) => {
*page50 = Some(true);
return Ok(page);
}
Err(e) if page50.is_none() && format!("{e:#}").contains("API 400") => {
*page50 = Some(false);
}
Err(e) => return Err(e),
}
}
match &feed {
Feed::News(symbol, min) => {
client
.news(*symbol, cursor, Some(PAGE_SIZE), *min, sort)
.await
}
Feed::Insider(symbol, min) => {
client
.insider_news(symbol, cursor, Some(PAGE_SIZE), *min, sort)
.await
}
}
}
fn error_event(key: String, e: anyhow::Error, mode: FeedMode, cursor: Option<&str>) -> Event {
let error = format!("{e:#}");
match mode {
FeedMode::Append => {
let gated = is_archive_gate(&error);
Event::PageError { key, error, gated }
}
FeedMode::Merge if cursor.is_some() && is_cursor_rejected(&error) => {
Event::PollReprime { key }
}
FeedMode::Merge => Event::PollError { key, error },
FeedMode::Replace => Event::Error { key, error },
}
}
fn is_cursor_rejected(error: &str) -> bool {
error.contains("API 400") || is_archive_gate(error)
}
pub async fn run(
initial_key: Option<String>,
mut cmds: UnboundedReceiver<Cmd>,
tx: UnboundedSender<SourceEvent>,
) {
let mut client = initial_key.and_then(|k| Client::new(k).ok());
let mut page50: Option<bool> = None;
while let Some(cmd) = cmds.recv().await {
match cmd {
Cmd::SetKey(key) => {
client = key.and_then(|k| Client::new(k).ok());
page50 = None;
}
Cmd::FetchNews {
symbol,
cursor,
min_relevance,
sort,
} => {
let key = news_key(symbol.as_deref());
let Some(client) = &client else {
send_error(&tx, key, "no AlphAI API key configured");
continue;
};
let mode = feed_mode(sort, cursor.as_deref());
let (page, sentiment) = match (&symbol, mode) {
(Some(s), FeedMode::Replace) => {
let (p, senti) = tokio::join!(
fetch_feed_page(
client,
Feed::News(Some(s), min_relevance),
None,
sort,
&mut page50,
),
client.sentiment(s)
);
(p, senti.ok())
}
_ => (
fetch_feed_page(
client,
Feed::News(symbol.as_deref(), min_relevance),
cursor.as_deref(),
sort,
&mut page50,
)
.await,
None,
),
};
let event = match page {
Ok(p) => Event::Feed {
key,
articles: p.results,
side: sentiment.map(FeedPayload::Sentiment),
next_cursor: p.next_cursor,
mode,
min_relevance,
},
Err(e) => error_event(key, e, mode, cursor.as_deref()),
};
if tx.send(SourceEvent::Alphai(event)).is_err() {
return;
}
}
Cmd::FetchTrending => {
let key = TRENDING_KEY.to_string();
let Some(client) = &client else {
send_error(&tx, key, "no AlphAI API key configured");
continue;
};
let event = match client.trending().await {
Ok(articles) => Event::Feed {
key,
articles,
side: None,
next_cursor: None,
mode: FeedMode::Replace,
min_relevance: None,
},
Err(e) => Event::Error {
key,
error: format!("{e:#}"),
},
};
if tx.send(SourceEvent::Alphai(event)).is_err() {
return;
}
}
Cmd::FetchInsider {
symbol,
cursor,
min_relevance,
sort,
} => {
let key = insider_key(&symbol);
let Some(client) = &client else {
send_error(&tx, key, "no AlphAI API key configured");
continue;
};
let mode = feed_mode(sort, cursor.as_deref());
let (page, trades) = match mode {
FeedMode::Replace => {
let (p, t) = tokio::join!(
fetch_feed_page(
client,
Feed::Insider(&symbol, min_relevance),
None,
sort,
&mut page50,
),
client.insider_trades(&symbol)
);
(p, t.ok())
}
_ => (
fetch_feed_page(
client,
Feed::Insider(&symbol, min_relevance),
cursor.as_deref(),
sort,
&mut page50,
)
.await,
None,
),
};
let event = match page {
Ok(p) => Event::Feed {
key,
articles: p.results,
side: trades.map(|t| FeedPayload::Insider(Box::new(t))),
next_cursor: p.next_cursor,
mode,
min_relevance,
},
Err(e) => error_event(key, e, mode, cursor.as_deref()),
};
if tx.send(SourceEvent::Alphai(event)).is_err() {
return;
}
}
Cmd::FetchEarnings { symbol } => {
let key = earnings_key(&symbol);
let Some(client) = &client else {
send_error(&tx, key, "no AlphAI API key configured");
continue;
};
let event = match client.earnings(&symbol).await {
Ok(data) => Event::Earnings {
key,
data: Box::new(data),
},
Err(e) if is_unknown_symbol(&format!("{e:#}")) => Event::Earnings {
key,
data: Box::new(TickerEarnings {
ticker: symbol,
unknown: true,
..Default::default()
}),
},
Err(e) => Event::Error {
key,
error: format!("{e:#}"),
},
};
if tx.send(SourceEvent::Alphai(event)).is_err() {
return;
}
}
Cmd::FetchCalendar { from, to } => {
let events = match &client {
Some(c) => c.calendar(&from, &to).await.unwrap_or_default(),
None => Vec::new(),
};
if tx
.send(SourceEvent::Alphai(Event::Calendar { events }))
.is_err()
{
return;
}
}
}
}
}
fn send_error(tx: &UnboundedSender<SourceEvent>, key: String, msg: &str) {
let _ = tx.send(SourceEvent::Alphai(Event::Error {
key,
error: msg.to_string(),
}));
}
#[derive(Deserialize)]
pub struct NewsPage {
#[serde(default)]
pub results: Vec<Article>,
#[serde(default)]
pub next_cursor: Option<String>,
}
#[derive(Deserialize)]
struct ErrorBody {
message: Option<String>,
detail: Option<String>,
error: Option<String>,
extra: Option<ErrorExtra>,
}
#[derive(Deserialize)]
struct ErrorExtra {
reason: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Article {
pub original: Original,
#[serde(default)]
pub enrichment: Enrichment,
#[serde(default)]
pub sources_count: Option<i64>,
#[serde(default)]
pub insider: Option<InsiderEvent>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InsiderEvent {
#[serde(default)]
pub side: Option<String>,
#[serde(default)]
pub transaction_code: Option<String>,
#[serde(default)]
pub shares: Option<String>,
#[serde(default)]
pub avg_price_usd: Option<String>,
#[serde(default)]
pub total_value_usd: Option<String>,
#[serde(default)]
pub is_10b5_1: bool,
#[serde(default)]
pub insider_name: String,
#[serde(default)]
pub insider_title: String,
#[serde(default)]
pub is_officer: bool,
#[serde(default)]
pub is_director: bool,
#[serde(default)]
pub is_ten_percent_owner: bool,
#[serde(default)]
pub transaction_date: Option<String>,
}
impl InsiderEvent {
pub fn role(&self) -> &str {
if !self.insider_title.is_empty() {
&self.insider_title
} else if self.is_officer {
"Officer"
} else if self.is_director {
"Director"
} else if self.is_ten_percent_owner {
"10% owner"
} else {
""
}
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct Original {
#[serde(default)]
pub uid: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub url: String,
#[serde(default)]
pub time_published: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub source_domain: String,
#[serde(default)]
pub ownership_form: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Enrichment {
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub tickers: Vec<String>,
#[serde(default)]
pub relevance_score: Option<i64>,
#[serde(default)]
pub ai_trading_insights: Option<Insights>,
#[serde(default)]
pub news_context_enhancement: Option<ContextEnhancement>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Insights {
#[serde(default)]
pub ticker_analysis: Vec<TickerAnalysis>,
#[serde(default)]
pub news_trading_value: Option<TradingValue>,
#[serde(default)]
pub alternative_perspectives: Option<Perspectives>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct TickerAnalysis {
#[serde(default)]
pub ticker: String,
#[serde(default)]
pub impact_analysis: Option<Impact>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Impact {
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub sentiment: Option<String>,
#[serde(default)]
pub price_impact_prediction: Option<String>,
#[serde(default)]
pub confidence: Option<String>,
#[serde(default)]
pub reasoning: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct TradingValue {
#[serde(default)]
pub actionability_score: Option<String>,
#[serde(default)]
pub information_novelty: Option<i64>,
#[serde(default)]
pub timing_relevance: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Perspectives {
#[serde(default)]
pub contrarian_view: Option<String>,
#[serde(default)]
pub overlooked_factors: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct ContextEnhancement {
#[serde(default)]
pub background_context: Option<String>,
#[serde(default)]
pub key_entities: Vec<KeyEntity>,
#[serde(default)]
pub market_relevance_summary: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct KeyEntity {
#[serde(default)]
pub name: String,
#[serde(default, rename = "type")]
pub kind: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
impl Article {
pub fn published(&self) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(&self.original.time_published)
.ok()
.map(|t| t.with_timezone(&Utc))
}
pub fn age(&self, now: DateTime<Utc>) -> String {
let Some(ts) = self.published() else {
return String::new();
};
let mins = (now - ts).num_minutes().max(0);
match mins {
0..=59 => format!("{mins}m"),
60..=1439 => format!("{}h", mins / 60),
_ => format!("{}d", mins / 1440),
}
}
pub fn impact_for(&self, ticker: &str) -> Option<&Impact> {
self.enrichment
.ai_trading_insights
.as_ref()?
.ticker_analysis
.iter()
.find(|t| t.ticker.eq_ignore_ascii_case(ticker))?
.impact_analysis
.as_ref()
}
pub fn sentiment_for(&self, ticker: &str) -> Option<&str> {
self.impact_for(ticker)?.sentiment.as_deref()
}
pub fn score(&self) -> i64 {
self.enrichment.relevance_score.unwrap_or(0)
}
pub fn trading_value(&self) -> Option<&TradingValue> {
self.enrichment
.ai_trading_insights
.as_ref()?
.news_trading_value
.as_ref()
}
pub fn novelty(&self) -> Option<i64> {
self.trading_value()?.information_novelty.filter(|&n| n > 0)
}
pub fn sources_badge(&self) -> Option<i64> {
self.sources_count.filter(|&n| n > 1)
}
pub fn alphai_url(&self) -> Option<String> {
article_url_for(&self.original.uid, &self.original.title, self.published())
}
}
pub fn article_url_for(uid: &str, title: &str, published: Option<DateTime<Utc>>) -> Option<String> {
let uid = uid.trim();
if uid.is_empty() {
return None;
}
let date = published?.format("%m-%d");
let slug = slugify(title);
if slug.is_empty() {
return None;
}
Some(format!("{SITE_URL}/news/article/{date}/{uid}/{slug}"))
}
pub fn is_earnings_filing(a: &Article) -> bool {
a.original.source_domain == "sec.gov" && a.enrichment.category.as_deref() == Some("earnings")
}
fn slugify(text: &str) -> String {
text.to_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-') || c.is_whitespace())
.map(|c| if c == '-' { ' ' } else { c })
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join("-")
}
#[derive(Clone, Debug, Deserialize)]
pub struct SentimentSummary {
#[serde(default)]
pub days: i64,
#[serde(default)]
pub total: i64,
#[serde(default)]
pub bullish: i64,
#[serde(default)]
pub neutral: i64,
#[serde(default)]
pub bearish: i64,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InsiderTrades {
#[serde(default)]
pub coverage_start: Option<String>,
#[serde(default)]
pub summary: Option<TradesSummary>,
#[serde(default)]
pub series_weekly: Vec<WeekBucket>,
#[serde(default)]
pub chart_events: Vec<TradeEvent>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct TradesSummary {
#[serde(default)]
pub last_12m: Option<TradesWindow>,
#[serde(default)]
pub top_insiders: Vec<TradeTopInsider>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct TradesWindow {
#[serde(default)]
pub buy_count: i64,
#[serde(default)]
pub sell_count: i64,
#[serde(default)]
pub buy_value_usd: Option<String>,
#[serde(default)]
pub sell_value_usd: Option<String>,
#[serde(default)]
pub unique_insiders: i64,
#[serde(default)]
pub pct_10b5_1: i64,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct TradeTopInsider {
#[serde(default)]
pub name: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub event_count: i64,
#[serde(default)]
pub net_value_usd: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct WeekBucket {
#[serde(default)]
pub week_start: String,
#[serde(default)]
pub buy_count: i64,
#[serde(default)]
pub sell_count: i64,
#[serde(default)]
pub buy_value_usd: Option<String>,
#[serde(default)]
pub sell_value_usd: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct TradeEvent {
#[serde(default)]
pub side: Option<String>,
#[serde(default)]
pub transaction_code: Option<String>,
#[serde(default)]
pub total_value_usd: Option<String>,
#[serde(default)]
pub tranche_count: Option<i64>,
#[serde(default)]
pub stake_change_pct: Option<String>,
#[serde(default)]
pub is_10b5_1: bool,
#[serde(default)]
pub late_filing: bool,
#[serde(default)]
pub transaction_date: Option<String>,
#[serde(default)]
pub news_uid: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct TickerEarnings {
#[serde(default)]
pub ticker: String,
#[serde(default)]
pub reports: Vec<EarningsRead>,
#[serde(default)]
pub next_report_date: Option<String>,
#[serde(skip)]
pub unknown: bool,
}
impl TickerEarnings {
pub fn latest(&self) -> Option<&EarningsRead> {
self.reports.first()
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct EarningsRead {
#[serde(default)]
pub uid: String,
#[serde(default)]
pub ticker: String,
#[serde(default)]
pub fiscal_period: String,
#[serde(default)]
pub time_published: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub source_type: String,
#[serde(default)]
pub analysis: EarningsReport,
}
impl EarningsRead {
pub fn report(&self) -> &EarningsReport {
&self.analysis
}
pub fn filed(&self) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(&self.time_published)
.ok()
.map(|t| t.with_timezone(&Utc))
}
pub fn form(&self) -> &'static str {
if self.source_type == "sec_form6k" {
"6-K"
} else {
"8-K"
}
}
pub fn alphai_url(&self) -> Option<String> {
article_url_for(&self.uid, &self.title, self.filed())
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct EarningsReport {
#[serde(default)]
pub company: String,
#[serde(default)]
pub ticker: String,
#[serde(default)]
pub fiscal_period: String,
#[serde(default)]
pub period_end: Option<String>,
#[serde(default)]
pub headline: String,
#[serde(default)]
pub verdict: String,
#[serde(default)]
pub verdict_reason: String,
#[serde(default)]
pub key_metrics: Vec<KeyMetric>,
#[serde(default)]
pub segments: Vec<Segment>,
#[serde(default)]
pub guidance: Option<Guidance>,
#[serde(default)]
pub vs_prior_guidance: Vec<GuidanceCheck>,
#[serde(default)]
pub capital_returns: Vec<String>,
#[serde(default)]
pub balance_sheet_cash_flow: Vec<String>,
#[serde(default)]
pub drivers: Vec<String>,
#[serde(default)]
pub concerns: Vec<String>,
#[serde(default)]
pub what_to_watch: Vec<String>,
#[serde(default)]
pub quotes: Vec<SpeakerQuote>,
#[serde(default)]
pub analysis: String,
#[serde(default)]
pub missing_items: Vec<String>,
#[serde(default)]
pub numbers_verified_from_document: bool,
}
impl EarningsReport {
pub fn narrative(&self) -> &str {
&self.analysis
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct KeyMetric {
#[serde(default)]
pub name: String,
#[serde(default)]
pub value: String,
#[serde(default)]
pub basis: String,
#[serde(default)]
pub prior_year: Option<String>,
#[serde(default)]
pub prior_quarter: Option<String>,
#[serde(default)]
pub yoy_change: Option<String>,
#[serde(default)]
pub qoq_change: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Segment {
#[serde(default)]
pub name: String,
#[serde(default)]
pub revenue: String,
#[serde(default)]
pub yoy_change: Option<String>,
#[serde(default)]
pub qoq_change: Option<String>,
#[serde(default)]
pub driver: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Guidance {
#[serde(default)]
pub period: String,
#[serde(default)]
pub revenue: Option<String>,
#[serde(default)]
pub gross_margin: Option<String>,
#[serde(default)]
pub operating_expenses: Option<String>,
#[serde(default)]
pub tax_rate: Option<String>,
#[serde(default)]
pub other: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct GuidanceCheck {
#[serde(default)]
pub metric: String,
#[serde(default)]
pub prior_guidance: String,
#[serde(default)]
pub actual: String,
#[serde(default)]
pub verdict: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct SpeakerQuote {
#[serde(default)]
pub speaker: String,
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub text: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct CalendarEvent {
#[serde(default)]
pub title: String,
#[serde(default)]
pub scheduled_at: String,
#[serde(default)]
pub phase: String,
#[serde(default)]
pub schedule_status: String,
#[serde(default)]
pub schedule_basis: String,
#[serde(default)]
pub importance: String,
#[serde(default)]
pub press_conference_at: Option<String>,
#[serde(default)]
pub has_sep: bool,
}
impl CalendarEvent {
pub fn scheduled(&self) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(&self.scheduled_at)
.ok()
.map(|t| t.with_timezone(&Utc))
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct CalendarEvents {
#[serde(default)]
pub events: Vec<CalendarEvent>,
}
pub fn fmt_shares(decimal: &str) -> String {
let Ok(v) = decimal.parse::<f64>() else {
return decimal.to_string();
};
let digits = format!("{:.0}", v.abs());
let mut out = String::new();
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i) % 3 == 0 {
out.push(',');
}
out.push(c);
}
if v < 0.0 { format!("-{out}") } else { out }
}
pub fn short_metric(value: &str) -> String {
let mut trimmed = value.trim();
if let Some(i) = trimmed.find(" as of ") {
trimmed = trimmed[..i].trim_end();
}
let mut out = trimmed.to_string();
for (long, short) in [
(" billion", "B"),
(" million", "M"),
(" percentage points", "pt"),
(" percentage point", "pt"),
(" basis points", "bp"),
(" pts", "pt"),
(" pt", "pt"),
(" percent", "%"),
(" per diluted share", ""),
(" per share", ""),
] {
out = out.replace(long, short);
}
out
}
const CHANGE_TAILS: [&str; 6] = [
" year over year",
" from a year ago",
" from the year-ago quarter",
" sequentially",
" quarter over quarter",
" from the prior quarter",
];
pub fn signed_change(change: &str) -> String {
let mut out = short_metric(change);
let lower = out.to_lowercase();
for tail in CHANGE_TAILS {
if let Some(cut) = lower.find(tail) {
out.truncate(cut);
break;
}
}
let t = out.trim();
for (word, sign) in [("up ", "+"), ("down ", "-")] {
if let Some(rest) = t.strip_prefix(word) {
return format!("{sign}{rest}");
}
}
if t.starts_with(|c: char| c.is_ascii_digit()) {
return format!("+{t}");
}
t.to_string()
}
pub fn short_fiscal_period(period: &str) -> String {
let lower = period.to_lowercase();
let quarter = [
("first quarter", 1),
("second quarter", 2),
("third quarter", 3),
("fourth quarter", 4),
("q1", 1),
("q2", 2),
("q3", 3),
("q4", 4),
]
.into_iter()
.find(|(word, _)| lower.contains(word))
.map(|(_, n)| n);
let year = lower
.split(|c: char| !c.is_ascii_digit())
.find(|t| t.len() == 4 && (t.starts_with('1') || t.starts_with('2')))
.and_then(|t| t.parse::<u32>().ok());
match (quarter, year) {
(Some(q), Some(y)) => format!("Q{q} FY{:02}", y % 100),
_ => period.to_string(),
}
}
pub fn fmt_usd(decimal: &str) -> String {
let Ok(v) = decimal.parse::<f64>() else {
return decimal.to_string();
};
let sign = if v < 0.0 { "-" } else { "" };
let a = v.abs();
if a >= 1e9 {
format!("{sign}${:.1}B", a / 1e9)
} else if a >= 1e6 {
format!("{sign}${:.1}M", a / 1e6)
} else if a >= 1e3 {
format!("{sign}${:.1}K", a / 1e3)
} else {
format!("{sign}${a:.0}")
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{
"results": [{
"original": {
"uid": "788e477c66f3849b",
"title": "NVIDIA beats on Q2 earnings",
"url": "https://example.com/nvda",
"time_published": "2026-07-10T12:30:00Z",
"summary": "Data-center revenue grew again.",
"source": "Example Wire",
"source_domain": "example.com"
},
"enrichment": {
"category": "earnings",
"tickers": ["NVDA"],
"relevance_score": 9,
"ai_trading_insights": {
"ticker_analysis": [{
"ticker": "NVDA",
"impact_analysis": {
"summary": "Beat lifts the data-center thesis.",
"sentiment": "positive",
"price_impact_prediction": "+2-4% near term",
"confidence": "high",
"reasoning": "Guidance raised on top of the beat."
}
}],
"news_trading_value": {
"actionability_score": "high",
"information_novelty": 7,
"timing_relevance": "pre-market"
},
"alternative_perspectives": {
"contrarian_view": "Growth is priced in.",
"overlooked_factors": "Export limits loom."
}
},
"news_context_enhancement": {
"background_context": "Third consecutive beat.",
"key_entities": [
{"name": "NVIDIA", "type": "company", "description": "GPU maker"}
],
"market_relevance_summary": "Sets the tone for semis."
}
},
"story_id": null,
"sources_count": 7,
"sources": null
}],
"next_cursor": null
}"#;
#[test]
fn parses_news_page() {
let page: NewsPage = serde_json::from_str(SAMPLE).unwrap();
assert_eq!(page.results.len(), 1);
let a = &page.results[0];
assert_eq!(a.original.title, "NVIDIA beats on Q2 earnings");
assert_eq!(a.score(), 9);
assert_eq!(a.sentiment_for("nvda"), Some("positive"));
assert_eq!(a.sentiment_for("AAPL"), None);
assert!(a.published().is_some());
let impact = a.impact_for("NVDA").unwrap();
assert_eq!(impact.confidence.as_deref(), Some("high"));
assert_eq!(
impact.price_impact_prediction.as_deref(),
Some("+2-4% near term")
);
assert_eq!(a.novelty(), Some(7));
assert_eq!(a.sources_badge(), Some(7));
assert_eq!(
a.trading_value().unwrap().actionability_score.as_deref(),
Some("high")
);
let ctx = a.enrichment.news_context_enhancement.as_ref().unwrap();
assert_eq!(ctx.key_entities[0].kind.as_deref(), Some("company"));
}
#[test]
fn parses_next_cursor() {
let page: NewsPage = serde_json::from_str(SAMPLE).unwrap();
assert_eq!(page.next_cursor, None);
let raw = r#"{"results": [], "next_cursor": "abc123"}"#;
let page: NewsPage = serde_json::from_str(raw).unwrap();
assert_eq!(page.next_cursor.as_deref(), Some("abc123"));
}
#[test]
fn archive_gate_is_detectable() {
assert!(is_archive_gate(ARCHIVE_GATE_MSG));
assert!(!is_archive_gate("AlphAI API 400 Bad Request: bad cursor"));
}
#[test]
fn only_delta_requests_carry_a_sort() {
assert_eq!(Sort::Published.param(), None);
assert_eq!(Sort::Ingested.param(), Some("ingested"));
assert_eq!(Sort::default(), Sort::Published);
}
#[test]
fn fetch_mode_follows_the_sort_and_the_cursor() {
assert_eq!(feed_mode(Sort::Published, None), FeedMode::Replace);
assert_eq!(feed_mode(Sort::Published, Some("c1")), FeedMode::Append);
assert_eq!(feed_mode(Sort::Ingested, None), FeedMode::Merge);
assert_eq!(feed_mode(Sort::Ingested, Some("d1")), FeedMode::Merge);
}
#[test]
fn poll_failures_split_by_what_the_poll_carried() {
let bad_cursor = || anyhow::anyhow!("AlphAI API 400: invalid cursor");
assert!(matches!(
error_event("k".into(), bad_cursor(), FeedMode::Merge, Some("d1")),
Event::PollReprime { .. }
));
assert!(matches!(
error_event(
"k".into(),
anyhow::anyhow!(ARCHIVE_GATE_MSG),
FeedMode::Merge,
Some("d1")
),
Event::PollReprime { .. }
));
assert!(matches!(
error_event("k".into(), bad_cursor(), FeedMode::Merge, None),
Event::PollError { .. }
));
assert!(matches!(
error_event(
"k".into(),
anyhow::anyhow!("AlphAI API 429: slow down"),
FeedMode::Merge,
Some("d1")
),
Event::PollError { .. }
));
assert!(matches!(
error_event(
"k".into(),
anyhow::anyhow!(ARCHIVE_GATE_MSG),
FeedMode::Append,
Some("c1")
),
Event::PageError { gated: true, .. }
));
assert!(matches!(
error_event("k".into(), bad_cursor(), FeedMode::Replace, None),
Event::Error { .. }
));
}
#[test]
fn novelty_and_sources_sentinels_hide() {
let raw = r#"{
"original": {"title": "x"},
"enrichment": {"ai_trading_insights": {"news_trading_value": {"information_novelty": 0}}},
"sources_count": 1
}"#;
let a: Article = serde_json::from_str(raw).unwrap();
assert_eq!(a.novelty(), None);
assert_eq!(a.sources_badge(), None);
let bare: Article = serde_json::from_str(r#"{"original": {"title": "y"}}"#).unwrap();
assert_eq!(bare.novelty(), None);
assert_eq!(bare.sources_badge(), None);
}
#[test]
fn parses_trending_bare_array() {
let raw = r#"[{"original": {"title": "Fed cuts"}, "sources_count": 4}]"#;
let items: Vec<Article> = serde_json::from_str(raw).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].sources_badge(), Some(4));
}
#[test]
fn parses_insider_article_fields() {
let raw = r#"{"original": {"title": "sold stock", "ownership_form": "indirect"}}"#;
let a: Article = serde_json::from_str(raw).unwrap();
assert_eq!(a.original.ownership_form.as_deref(), Some("indirect"));
let plain: Article = serde_json::from_str(r#"{"original": {"title": "no form"}}"#).unwrap();
assert_eq!(plain.original.ownership_form, None);
assert!(
plain.insider.is_none(),
"legacy rows must parse without the block"
);
}
#[test]
fn parses_structured_insider_block() {
let raw = r#"{
"original": {"title": "STEVENS MARK A sold shares"},
"insider": {
"side": "sell",
"transaction_code": "S",
"shares": "25000.0000",
"avg_price_usd": "187.3200",
"total_value_usd": "4683000.00",
"is_10b5_1": true,
"insider_name": "STEVENS MARK A",
"insider_title": "Director",
"is_officer": false,
"is_director": true,
"is_ten_percent_owner": false,
"transaction_date": "2026-07-09"
}
}"#;
let a: Article = serde_json::from_str(raw).unwrap();
let t = a.insider.as_ref().unwrap();
assert_eq!(t.side.as_deref(), Some("sell"));
assert_eq!(t.transaction_code.as_deref(), Some("S"));
assert_eq!(fmt_shares(t.shares.as_deref().unwrap()), "25,000");
assert_eq!(fmt_usd(t.total_value_usd.as_deref().unwrap()), "$4.7M");
assert!(t.is_10b5_1);
assert_eq!(t.role(), "Director");
assert_eq!(t.transaction_date.as_deref(), Some("2026-07-09"));
let raw = r#"{
"original": {"title": "grant"},
"insider": {"side": "other", "shares": "100", "is_10b5_1": false,
"insider_name": "X", "insider_title": "",
"is_officer": true, "is_director": false,
"is_ten_percent_owner": false,
"avg_price_usd": null, "total_value_usd": null}
}"#;
let a: Article = serde_json::from_str(raw).unwrap();
let t = a.insider.as_ref().unwrap();
assert_eq!(t.total_value_usd, None);
assert_eq!(t.role(), "Officer");
}
#[test]
fn shares_formatting_groups_thousands() {
assert_eq!(fmt_shares("25000.0000"), "25,000");
assert_eq!(fmt_shares("512"), "512");
assert_eq!(fmt_shares("1234567"), "1,234,567");
assert_eq!(fmt_shares("-2500"), "-2,500");
assert_eq!(fmt_shares("garbage"), "garbage");
}
#[test]
fn builds_alphai_article_url() {
let page: NewsPage = serde_json::from_str(SAMPLE).unwrap();
let a = &page.results[0];
assert_eq!(a.original.uid, "788e477c66f3849b");
assert_eq!(
a.alphai_url().unwrap(),
"https://alphai.io/news/article/07-10/788e477c66f3849b/nvidia-beats-on-q2-earnings"
);
let mut b = a.clone();
b.original.uid = String::new();
assert!(b.alphai_url().is_none());
let mut c = a.clone();
c.original.title = "Новости — заголовок кириллицей".into();
assert!(c.alphai_url().is_none());
}
#[test]
fn slugify_matches_site_convention() {
assert_eq!(
slugify("NVIDIA beats on Q2 earnings"),
"nvidia-beats-on-q2-earnings"
);
assert_eq!(slugify("Apple's Q2: beats!"), "apples-q2-beats");
assert_eq!(
slugify("AI - the new gold rush"),
"ai-the-new-gold-rush"
);
assert_eq!(slugify("Привет"), "");
}
#[test]
fn tolerates_missing_enrichment() {
let raw = r#"{"results": [{"original": {"title": "x"}}]}"#;
let page: NewsPage = serde_json::from_str(raw).unwrap();
assert_eq!(page.results[0].score(), 0);
assert!(page.results[0].sentiment_for("NVDA").is_none());
}
#[test]
fn parses_insider_trades() {
let raw = r#"{
"ticker": "CRWV",
"coverage_start": "2025-08-14",
"summary": {
"last_3m": { "buy_count": 0, "sell_count": 58, "buy_value_usd": null,
"sell_value_usd": "1900000000", "unique_insiders": 5, "pct_10b5_1": 64 },
"last_12m": { "buy_count": 0, "sell_count": 487, "buy_value_usd": null,
"sell_value_usd": "6420000000", "unique_insiders": 11, "pct_10b5_1": 70 },
"all_time": { "buy_count": 0, "sell_count": 487, "buy_value_usd": null,
"sell_value_usd": "6420000000", "unique_insiders": 11, "pct_10b5_1": 70 },
"top_insiders": [
{ "name": "Intrator Michael N", "title": "CEO and President",
"event_count": 134, "net_value_usd": "-505000000" }
]
},
"series_weekly": [
{ "week_start": "2026-07-27", "buy_count": 0, "sell_count": 9,
"buy_value_usd": "0", "sell_value_usd": "48200000" },
{ "week_start": "2026-08-03", "buy_count": 0, "sell_count": 3,
"buy_value_usd": "0", "sell_value_usd": "30600000" }
],
"chart_events": [
{ "side": "sell", "transaction_code": "S", "ownership_form": "I",
"security_title": "Common Stock", "shares": "107692",
"avg_price_usd": "91.8", "total_value_usd": "9886021.65",
"tranche_count": 8, "stake_change_pct": "-100.0",
"is_10b5_1": true, "late_filing": false,
"insider_name": "Intrator Michael N", "insider_title": "CEO and President",
"is_officer": true, "is_director": true, "is_ten_percent_owner": true,
"transaction_date": "2026-08-04", "filed_at": "2026-08-07T00:36:56+00:00",
"news_uid": "788e477c66f3849b", "news_title": "…", "has_article": true },
{ "side": "sell", "transaction_code": "D", "shares": "1000",
"avg_price_usd": null, "total_value_usd": null,
"is_10b5_1": false, "insider_name": "X", "insider_title": "",
"transaction_date": "2026-07-20", "news_uid": null }
],
"events": [],
"next_cursor": "cD0yMDI2"
}"#;
let t: InsiderTrades = serde_json::from_str(raw).unwrap();
assert_eq!(t.coverage_start.as_deref(), Some("2025-08-14"));
let s = t.summary.as_ref().unwrap();
let m12 = s.last_12m.as_ref().unwrap();
assert_eq!((m12.buy_count, m12.sell_count), (0, 487));
assert_eq!(m12.buy_value_usd, None);
assert_eq!(fmt_usd(m12.sell_value_usd.as_deref().unwrap()), "$6.4B");
assert_eq!(m12.unique_insiders, 11);
assert_eq!(s.top_insiders[0].event_count, 134);
assert_eq!(
fmt_usd(s.top_insiders[0].net_value_usd.as_deref().unwrap()),
"-$505.0M"
);
assert_eq!(t.series_weekly.len(), 2);
assert_eq!(t.series_weekly[0].week_start, "2026-07-27");
assert_eq!(t.series_weekly[0].sell_count, 9);
let e = &t.chart_events[0];
assert_eq!(e.side.as_deref(), Some("sell"));
assert_eq!(e.tranche_count, Some(8));
assert_eq!(e.stake_change_pct.as_deref(), Some("-100.0"));
assert_eq!(e.news_uid.as_deref(), Some("788e477c66f3849b"));
let d = &t.chart_events[1];
assert_eq!(d.transaction_code.as_deref(), Some("D"));
assert_eq!(d.total_value_usd, None);
let bare: InsiderTrades = serde_json::from_str(r#"{"ticker": "X"}"#).unwrap();
assert!(bare.summary.is_none());
assert!(bare.chart_events.is_empty());
}
#[test]
fn usd_formatting_bands() {
assert_eq!(fmt_usd("512"), "$512");
assert_eq!(fmt_usd("2500"), "$2.5K");
assert_eq!(fmt_usd("1300000000"), "$1.3B");
assert_eq!(fmt_usd("garbage"), "garbage");
}
#[tokio::test]
#[ignore = "live API call; needs ALPHAI_API_KEY"]
async fn live_api_smoke() {
let key = std::env::var("ALPHAI_API_KEY").expect("set ALPHAI_API_KEY");
let client = Client::new(key).unwrap();
let news = client
.news(
Some("NVDA"),
None,
Some(PAGE_SIZE),
Some(4),
Sort::Published,
)
.await
.unwrap();
assert!(!news.results.is_empty(), "empty NVDA news feed");
assert!(
news.results.len() <= PAGE_SIZE as usize,
"page over PAGE_SIZE"
);
assert!(news.results[0].original.title.len() > 3);
let cursor = news.next_cursor.expect("no next_cursor on page 1");
let page2 = client
.news(Some("NVDA"), Some(&cursor), None, Some(4), Sort::Published)
.await
.unwrap();
assert!(!page2.results.is_empty(), "empty second news page");
let senti = client.sentiment("NVDA").await.unwrap();
assert!(senti.days > 0);
let filings = client
.insider_news("NVDA", None, None, Some(4), Sort::Published)
.await
.unwrap();
assert!(!filings.results.is_empty(), "empty NVDA insider feed");
assert!(
filings.results.iter().any(|a| a.insider.is_some()),
"no structured insider block on the live feed"
);
let trades = client.insider_trades("NVDA").await.unwrap();
let windows = trades.summary.expect("no summary block on insider-trades");
assert!(windows.last_12m.is_some(), "no last_12m window");
assert!(
!trades.chart_events.is_empty(),
"empty chart_events for NVDA"
);
assert!(
!trades.series_weekly.is_empty(),
"empty series_weekly for NVDA"
);
let trending = client.trending().await.unwrap();
assert!(!trending.is_empty(), "empty trending feed");
let earnings = client.earnings("NVDA").await.unwrap();
let read = earnings.latest().expect("no earnings read for NVDA");
assert!(
!read.report().key_metrics.is_empty(),
"a published read always carries metrics"
);
assert!(read.report().numbers_verified_from_document);
let bare = client.earnings("KO").await.unwrap();
assert!(
bare.latest().is_none() || !bare.reports.is_empty(),
"the empty history must parse either way"
);
let missing = client.earnings("ZZZQQ").await.unwrap_err();
assert!(
is_unknown_symbol(&format!("{missing:#}")),
"an unknown ticker stopped answering 404: {missing:#}"
);
let today = Utc::now().date_naive();
let events = client
.calendar(
&today.to_string(),
&(today + chrono::Duration::days(CALENDAR_DAYS)).to_string(),
)
.await
.unwrap();
assert!(!events.is_empty(), "empty calendar window");
assert!(events.iter().all(|e| e.scheduled().is_some()));
let prime = client
.news(Some("NVDA"), None, Some(PAGE_SIZE), Some(4), Sort::Ingested)
.await
.unwrap();
assert!(!prime.results.is_empty(), "empty priming delta page");
let delta_cursor = prime.next_cursor.expect("no position on the priming page");
let delta = client
.news(
Some("NVDA"),
Some(&delta_cursor),
None,
Some(4),
Sort::Ingested,
)
.await
.unwrap();
assert!(
delta.next_cursor.is_some(),
"delta mode dropped the position"
);
let crossed = match client
.news(Some("NVDA"), Some(&cursor), None, Some(4), Sort::Ingested)
.await
{
Ok(_) => panic!("a published cursor was accepted in delta mode"),
Err(e) => format!("{e:#}"),
};
assert!(
crossed.contains("API 400"),
"crossed cursor did not 400: {crossed}"
);
assert!(
is_cursor_rejected(&crossed),
"a crossed cursor must reprime"
);
let insider_delta = client
.insider_news("NVDA", None, Some(PAGE_SIZE), Some(4), Sort::Ingested)
.await
.unwrap();
assert!(
insider_delta.next_cursor.is_some(),
"insider feed refused a delta position"
);
}
const EARNINGS_SAMPLE: &str = r#"{
"ticker": "NVDA",
"next_report_date": "2026-11-17",
"reports": [{
"uid": "352613cc3f6089cc",
"time_published": "2026-08-26T20:21:19Z",
"title": "NVIDIA CORP (NVDA): Results of Operations and Financial Condition",
"source_type": "sec_form8k",
"ticker": "NVDA",
"fiscal_period": "Second Quarter Fiscal 2027",
"analysis": {
"company": "NVIDIA CORP",
"ticker": "NVDA",
"fiscal_period": "Second Quarter Fiscal 2027",
"period_end": "July 26, 2026",
"headline": "NVIDIA Announces Financial Results for Second Quarter Fiscal 2027",
"verdict": "strong",
"verdict_reason": "Revenue grew 18% sequentially and 106% year over year.",
"key_metrics": [
{"name": "Revenue", "value": "$96,221 million", "basis": "GAAP",
"prior_year": "$46,743 million", "prior_quarter": "$81,615 million",
"yoy_change": "106%", "qoq_change": "18%"},
{"name": "Cost of revenue", "value": "$24,079 million", "basis": "GAAP",
"prior_year": "$12,890 million", "prior_quarter": "$20,458 million",
"yoy_change": null, "qoq_change": null},
{"name": "Basic earnings per share", "value": "$2.47 per share", "basis": "GAAP",
"prior_year": "$1.08 per share", "prior_quarter": null,
"yoy_change": null, "qoq_change": null},
{"name": "Non-GAAP diluted earnings per share", "value": "$2.22 per diluted share",
"basis": "non-GAAP", "prior_year": "$1.01 per diluted share",
"prior_quarter": "$1.87 per diluted share",
"yoy_change": "120%", "qoq_change": "19%"}
],
"segments": [{"name": "Data Center", "revenue": "$89.0 billion",
"yoy_change": "117%", "qoq_change": "18%",
"driver": "Vera Rubin ramping into full production."}],
"guidance": {"period": "Third quarter fiscal 2027",
"revenue": "$108.0 billion, plus or minus 2%",
"gross_margin": "74.0%, plus or minus 50 basis points",
"operating_expenses": null, "tax_rate": null, "other": []},
"vs_prior_guidance": [],
"capital_returns": ["Returned $26.0 billion to shareholders."],
"balance_sheet_cash_flow": [],
"drivers": [],
"concerns": ["No Data Center compute revenue from China is assumed."],
"what_to_watch": ["Vera Rubin ramp."],
"quotes": [{"speaker": "Jensen Huang", "role": "CEO", "text": "Demand is extraordinary."}],
"analysis": "First paragraph.\n\nSecond paragraph.",
"missing_items": ["Segment operating income."],
"numbers_verified_from_document": true
}
}]
}"#;
#[test]
fn parses_ticker_earnings() {
let data: TickerEarnings = serde_json::from_str(EARNINGS_SAMPLE).unwrap();
assert_eq!(data.ticker, "NVDA");
assert_eq!(data.next_report_date.as_deref(), Some("2026-11-17"));
assert!(!data.unknown, "the wire never sets the terminal-state flag");
let read = data.latest().unwrap();
assert_eq!(read.form(), "8-K");
assert_eq!(read.report().key_metrics.len(), 4);
assert_eq!(
read.report().guidance.as_ref().unwrap().period,
"Third quarter fiscal 2027"
);
assert_eq!(read.report().narrative().lines().count(), 3);
assert!(
read.alphai_url()
.unwrap()
.contains("/news/article/08-26/352613cc3f6089cc/")
);
}
#[test]
fn parses_a_foreign_issuer_read() {
let data: TickerEarnings = serde_json::from_str(
r#"{"ticker": "TSM", "next_report_date": null, "reports": [{
"uid": "8aec41a2fdb476c0",
"time_published": "2026-08-11T11:45:48Z",
"title": "TSMC 6-K",
"source_type": "sec_form6k",
"ticker": "TSM",
"fiscal_period": "six months ended June 30, 2026",
"analysis": {
"company": "Taiwan Semiconductor Manufacturing Company Limited",
"fiscal_period": "six months ended June 30, 2026",
"verdict": "solid",
"key_metrics": [{"name": "Second quarter consolidated revenue",
"value": "NT$1,270.38 billion", "basis": "other",
"prior_year": null, "prior_quarter": null,
"yoy_change": null, "qoq_change": null}],
"guidance": null,
"analysis": "Board approved the report."
}}]}"#,
)
.unwrap();
let read = data.latest().unwrap();
assert_eq!(read.form(), "6-K");
assert!(read.report().guidance.is_none());
assert!(read.report().segments.is_empty());
assert_eq!(data.next_report_date, None);
}
#[test]
fn parses_an_empty_earnings_history() {
let data: TickerEarnings = serde_json::from_str(
r#"{"ticker":"AVGO","reports":[],"next_report_date":"2026-09-02"}"#,
)
.unwrap();
assert!(data.latest().is_none());
assert_eq!(data.next_report_date.as_deref(), Some("2026-09-02"));
}
#[test]
fn parses_calendar_events() {
let page: CalendarEvents = serde_json::from_str(
r#"{"events":[{"title":"CPI (Consumer Price Index)","scheduled_at":"2026-09-11T12:30:00Z",
"phase":"upcoming","schedule_status":"scheduled","schedule_basis":"official",
"importance":"high","press_conference_at":null,"has_sep":false}]}"#,
)
.unwrap();
assert_eq!(page.events.len(), 1);
assert!(page.events[0].scheduled().is_some());
}
#[test]
fn short_metric_shortens_units_without_rounding() {
assert_eq!(short_metric("$96,221 million"), "$96,221M");
assert_eq!(short_metric("NT$1,270.38 billion"), "NT$1,270.38B");
assert_eq!(short_metric("$2.46 per diluted share"), "$2.46");
assert_eq!(
short_metric("$10,605 million as of January 25, 2026"),
"$10,605M"
);
assert_eq!(short_metric("50.1 percent"), "50.1%");
assert_eq!(short_metric("—"), "—");
assert_ne!(short_metric("$96,221 million"), "$96.2B");
}
#[test]
fn signed_change_adds_only_a_sign() {
assert_eq!(signed_change("18%"), "+18%");
assert_eq!(signed_change("2.6 pts"), "+2.6pt");
assert_eq!(signed_change("-5%"), "-5%");
assert_eq!(signed_change("up 16 percent year over year"), "+16%");
assert_eq!(signed_change("down 5 percent sequentially"), "-5%");
assert_eq!(signed_change("—"), "—");
}
#[test]
fn fiscal_periods_shorten_when_they_name_a_quarter() {
assert_eq!(short_fiscal_period("Second Quarter Fiscal 2027"), "Q2 FY27");
assert_eq!(short_fiscal_period("fiscal 2026 third quarter"), "Q3 FY26");
assert_eq!(
short_fiscal_period("six months ended June 30, 2026"),
"six months ended June 30, 2026"
);
}
#[test]
fn unknown_symbol_is_recognised() {
assert!(is_unknown_symbol(
"AlphAI API 404 Not Found: Unknown symbol 'ZZZQQ'."
));
assert!(!is_unknown_symbol("AlphAI API 400 Bad Request: bad cursor"));
}
#[test]
fn earnings_filings_are_told_from_coverage() {
let filing: Article = serde_json::from_str(
r#"{"original":{"source_domain":"sec.gov","source":"SEC EDGAR 8-K"},
"enrichment":{"category":"earnings"}}"#,
)
.unwrap();
let coverage: Article = serde_json::from_str(
r#"{"original":{"source_domain":"reuters.com"},"enrichment":{"category":"earnings"}}"#,
)
.unwrap();
let form4: Article = serde_json::from_str(
r#"{"original":{"source_domain":"sec.gov"},"enrichment":{"category":"insider"}}"#,
)
.unwrap();
assert!(is_earnings_filing(&filing));
assert!(!is_earnings_filing(&coverage));
assert!(!is_earnings_filing(&form4));
}
#[test]
fn page_size_is_allowed_on_every_tier() {
assert!((1..=20).contains(&PAGE_SIZE));
}
#[test]
fn age_buckets() {
let now = DateTime::parse_from_rfc3339("2026-07-10T12:00:00Z")
.unwrap()
.with_timezone(&Utc);
let mut a: Article =
serde_json::from_str(r#"{"original": {"time_published": "2026-07-10T11:20:00Z"}}"#)
.unwrap();
assert_eq!(a.age(now), "40m");
a.original.time_published = "2026-07-10T02:00:00Z".into();
assert_eq!(a.age(now), "10h");
a.original.time_published = "2026-07-01T02:00:00Z".into();
assert_eq!(a.age(now), "9d");
a.original.time_published = "not-a-date".into();
assert_eq!(a.age(now), "");
}
}