Ctrl K
ComunidadGuías…

src/alphai.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! AlphAI public REST API client (https://alphai.io).
//!
//! Powers the News and Insider views: relevance-scored financial news and
//! SEC Form 4 insider activity. Needs an `ak_live_…` API key (free tier at
//! alphai.io, 20 req/min and 100 req/day), so fetches are demand-driven and
//! cached: the app only asks for the symbol on screen and re-asks after
//! `CACHE_TTL`. Keep it that way — a per-poll fetch would burn the free
//! daily budget in minutes.
//!
//! Re-asking is a delta poll (`Sort::Ingested`) rather than a refetch of the
//! newest page: an article reaches the feed later than it was published, so
//! the published head page is exactly where an arrival is NOT. Same cadence,
//! same one request, rows that would otherwise never be shown.

use std::time::Duration;

use anyhow::{Context, Result, bail};
use chrono::{DateTime, NaiveDate, Utc};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};

use crate::category::Categories;
use crate::poller::SourceEvent;

pub mod earnings_calendar;
pub use earnings_calendar::{ReportEvent, ReportRequest, ReportStatus};

pub const DEFAULT_BASE_URL: &str = "https://api.alphai.io";

/// The public site, for article page links (distinct from the API base).
pub const SITE_URL: &str = "https://alphai.io";

/// How long a fetched bundle stays fresh before a view triggers a re-fetch.
/// This is the default; `[ui] alphai_ttl_secs` overrides it per user, with
/// a floor guarding the request budget (`config::ALPHAI_TTL_RANGE`).
pub const CACHE_TTL: Duration = Duration::from_secs(300);

/// Rows per feed page. The API defaults to 10 but accepts up to 20 on every
/// tier (21 and above need Pro, see `fetch_feed_page`), and a wider page is
/// free: it is the same one request, it delays the next paging request, and
/// it widens the window in which a late-arriving article can still be seen.
/// Articles reach the feed well after their publish time, so a narrow page
/// hides them behind the horizon of what a refetch ever looks at.
pub const PAGE_SIZE: u8 = 20;

/// Cache key for the market-wide (unfiltered) news feed.
pub const MARKET_KEY: &str = "*";

/// Cache key for the trending feed (`~` cannot appear in a ticker).
pub const TRENDING_KEY: &str = "~trending";

/// Shown when paging back hits the plan's news-archive horizon. Terminal for
/// the current feed: no retry will help, only a higher tier. Kept short: it
/// renders inside the list border even in the side-by-side layout.
pub const ARCHIVE_GATE_MSG: &str = "plan archive limit · upgrade: alphai.io/pricing";

/// Whether a fetch error is the archive gate (see `ARCHIVE_GATE_MSG`).
pub fn is_archive_gate(error: &str) -> bool {
    error.contains("archive limit")
}

/// Whether a fetch failed because the API owns no such symbol (a 404 with
/// `unknown_symbol`). Terminal like the archive gate: no retry helps, so the
/// view says so instead of offering `r`. Crypto and typos land here.
pub fn is_unknown_symbol(error: &str) -> bool {
    error.contains("API 404")
}

/// Keep the HTTP status available to the calendar queue without coupling its
/// pause policy to the wording of a user-facing error.
#[derive(Debug)]
struct ApiError {
    status: reqwest::StatusCode,
    message: String,
}

impl std::fmt::Display for ApiError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.message)
    }
}

impl std::error::Error for ApiError {}

fn calendar_error(key: String, error: anyhow::Error) -> Event {
    let blocked = error
        .downcast_ref::<ApiError>()
        .is_some_and(|e| matches!(e.status.as_u16(), 401 | 403 | 429));
    let error = format!("{error:#}");
    if blocked {
        Event::CalendarBlocked { key, error }
    } else {
        Event::Error { key, error }
    }
}

pub struct Client {
    http: reqwest::Client,
    base: String,
    key: String,
}

/// Filters shared by every page and delta poll of a news feed.
#[derive(Clone, Copy)]
pub struct NewsFilters {
    pub min_relevance: Option<u8>,
    pub categories: Categories,
    pub collapse: bool,
}

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()?;
        let base = base.trim().trim_end_matches('/').to_string();
        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 {
            return Err(ApiError {
                status,
                message: "invalid AlphAI API key, press s to update it (free keys: alphai.io)"
                    .into(),
            }
            .into());
        }
        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
            let wait = resp
                .headers()
                .get("retry-after")
                .and_then(|v| v.to_str().ok())
                .unwrap_or("60");
            return Err(ApiError {
                status,
                message: format!(
                    "AlphAI rate limit hit, retry in {wait}s (Free tier: 20/min, 100/day)"
                ),
            }
            .into());
        }
        if !status.is_success() {
            let body = resp.text().await.unwrap_or_default();
            let parsed = serde_json::from_str::<ErrorBody>(&body).ok();
            // Paging back past the plan's archive horizon (Free 30 days,
            // Basic 90) is a 403 with a machine-readable reason.
            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());
            return Err(ApiError {
                status,
                message: format!("AlphAI API {status}: {msg}"),
            }
            .into());
        }
        resp.json().await.context("bad JSON from AlphAI")
    }

    /// One page of enriched news; `symbol: None` = market-wide feed, `cursor`
    /// pages back (opaque, from the prior page's `next_cursor`), `page_size`
    /// takes 1 to 20 on every tier and up to 50 only on Pro keys (omit for
    /// the server default of 10; anything over the tier's cap is a 400, never
    /// a silent clamp), `min_relevance`
    /// filters server-side by score 1..10 (the server defaults to 4 when
    /// omitted) so low-score rows never occupy page slots.
    /// `filters.collapse` requests one representative per story in either
    /// scope (`sources_count` reports the outlet count). The API applies the
    /// other filters to that representative; turning grouping off includes
    /// matching articles whose representative does not pass those filters.
    /// `filters.categories` narrows the feed to the categories the reader picked,
    /// also on the server (see `Categories::param`).
    /// `sort` picks the ordering: `Published` is the reverse-chronological
    /// feed this pages through, `Ingested` is delta mode (see `Sort`), where
    /// the same `cursor` slot carries a position in the arrival stream.
    pub async fn news(
        &self,
        symbol: Option<&str>,
        cursor: Option<&str>,
        page_size: Option<u8>,
        filters: NewsFilters,
        sort: Sort,
    ) -> Result<NewsPage> {
        let mut query: Vec<(&str, &str)> = Vec::new();
        if let Some(s) = symbol {
            query.push(("symbol", s));
        }
        if filters.collapse {
            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 = filters.min_relevance.map(|n| n.to_string());
        if let Some(min) = &min {
            query.push(("min_relevance", min));
        }
        let category = filters.categories.param();
        if let Some((name, list)) = &category {
            query.push((name, list));
        }
        if let Some(order) = sort.param() {
            query.push(("sort", order));
        }
        self.get_json("/api/news/", &query).await
    }

    /// Top 10 stories of the trailing 48h (relevance 8+, server-collapsed).
    /// The response is a bare array, not the paginated news shape.
    pub async fn trending(&self) -> Result<Vec<Article>> {
        self.get_json("/api/news/trending/", &[]).await
    }

    /// One page of the SEC Form 4 insider feed for one symbol; `cursor`,
    /// `page_size` and `min_relevance` behave as in `news`. Insider rows are
    /// scored from the event's total dollar value, so the relevance filter
    /// doubles as a trade-size filter (7 keeps roughly the $10M+ trades).
    /// `sort` works as in `news`, and delta mode matters most here: a Form 4
    /// is filed days after the trade it reports, so a new filing routinely
    /// enters the feed below the head of the publish-ordered page.
    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
    }

    /// 7-day bullish/neutral/bearish rollup from press coverage.
    pub async fn sentiment(&self, ticker: &str) -> Result<SentimentSummary> {
        self.get_json(&format!("/api/symbols/{ticker}/sentiment-summary/"), &[])
            .await
    }

    /// Form 4 chart bundle: 3m/12m/all-time rollups with top insiders,
    /// Monday-keyed weekly dollar buckets and every chart event of the
    /// trailing 365 days, in one request. `page_size=1` keeps the paginated
    /// events table (the TUI's list is the news feed) out of the payload.
    pub async fn insider_trades(&self, ticker: &str) -> Result<InsiderTrades> {
        self.get_json(
            &format!("/api/symbols/{ticker}/insider-trades/"),
            &[("page_size", "1")],
        )
        .await
    }

    /// Every published earnings read for one ticker, newest first, plus the
    /// date of its next report. One request covers the whole surface: the
    /// reads carry their full analysis, so nothing needs a second fetch.
    /// A ticker with no read answers 200 with an empty list; one no listing
    /// owns answers 404 (see `is_unknown_symbol`).
    pub async fn earnings(&self, ticker: &str) -> Result<TickerEarnings> {
        self.get_json(&format!("/api/symbols/{ticker}/earnings/"), &[])
            .await
    }

    /// The official schedule of US macro releases in `[from, to)`, dates as
    /// `YYYY-MM-DD`. Market-wide and unpaginated: one request covers every
    /// series in the window.
    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)
    }

    pub async fn earnings_calendar(&self, request: &ReportRequest) -> Result<Vec<ReportEvent>> {
        anyhow::ensure!(
            !request.tickers.is_empty() && request.tickers.len() <= 50,
            "earnings calendar needs 1 to 50 tickers"
        );
        anyhow::ensure!(
            request
                .tickers
                .iter()
                .all(|s| earnings_calendar::valid_ticker(s)),
            "invalid earnings calendar ticker"
        );
        anyhow::ensure!(
            (1..=45).contains(&(request.to - request.from).num_days()),
            "invalid earnings calendar window"
        );
        let page: earnings_calendar::ReportPage = self
            .get_json(
                "/api/calendar/earnings/",
                &[
                    ("from_date", &request.from.to_string()),
                    ("to_date", &request.to.to_string()),
                    ("ticker", &request.tickers.join(",")),
                    ("detail", "full"),
                ],
            )
            .await?;
        page.validate(request)
    }
}

// ---------------------------------------------------------------------------
// Background task: the UI sends commands, results come back as SourceEvents
// on the same channel the price poller uses.

/// Ordering a feed fetch asks for.
///
/// `Published` is the reverse-chronological feed: no cursor means the newest
/// page, and `next_cursor` walks back into history.
///
/// `Ingested` is delta polling, "what appeared in the feed since my last
/// poll": rows in the order they became available, ascending. An article
/// reaches the feed later than it was published (a median of roughly half an
/// hour for general news, days for a Form 4, which is filed after the trade),
/// so most arrivals land below the head of the published page and a refetch
/// of that page can never see them. Without a cursor the server answers with
/// the newest page and parks the position at the feed head; with one it
/// answers with what arrived since, and an empty page means caught up, so its
/// `next_cursor` is always set. The two modes mint separate cursor families
/// and a cursor replayed into the other mode is a 400, which is why the app
/// keeps the paging cursor and the poll cursor in separate fields.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Sort {
    #[default]
    Published,
    Ingested,
}

impl Sort {
    /// Wire value, or None when the request carries no `sort` at all:
    /// published is the server's default, so every published request stays
    /// byte for byte what it was before delta mode existed.
    fn param(self) -> Option<&'static str> {
        match self {
            Self::Published => None,
            Self::Ingested => Some("ingested"),
        }
    }
}

/// What a fetch's result does to the bundle it lands on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FeedMode {
    /// Head page: replace the bundle, side payload included.
    Replace,
    /// Cursor page of the published feed: extend it with older rows.
    Append,
    /// Delta page: merge arrivals into the top, keeping the loaded pages,
    /// the side payload and the row under the reader's cursor.
    Merge,
}

/// The mode a fetch's result lands in, from what the fetch asked for.
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 {
    /// Swap the API key at runtime (settings screen). None disables fetching.
    SetKey(Option<String>),
    /// Fetch news (+ sentiment when symbol-scoped). None = market-wide.
    /// A cursor means "load the next page" of an already shown feed.
    /// `min_relevance` is the score filter the app wants (echoed back on the
    /// resulting `Event::Feed` so the bundle can record it). `sort` picks the
    /// published feed or a delta poll of it (see `Sort`); a delta fetch skips
    /// the sentiment rollup, which the bundle already holds. `categories`
    /// is echoed back the same way as the score filter.
    FetchNews {
        symbol: Option<String>,
        cursor: Option<String>,
        min_relevance: Option<u8>,
        categories: Categories,
        collapse: bool,
        sort: Sort,
    },
    /// Fetch the 48h trending top 10 (cached under `TRENDING_KEY`).
    FetchTrending,
    /// Fetch the insider feed + the Form 4 chart bundle for one symbol; a
    /// cursor pages. `min_relevance` works as in `FetchNews` (insider
    /// scores track the trade size, so this is effectively a size filter).
    /// `sort` works as in `FetchNews`, and a delta fetch skips the chart
    /// bundle the same way.
    FetchInsider {
        symbol: String,
        cursor: Option<String>,
        min_relevance: Option<u8>,
        sort: Sort,
    },
    /// Fetch one ticker's earnings reads and its next report date.
    FetchEarnings {
        symbol: String,
        cache_symbol: String,
    },
    FetchReportCalendar(ReportRequest),
    /// Fetch the macro calendar window `[from, to)`, dates as `YYYY-MM-DD`.
    FetchCalendar {
        from: String,
        to: String,
    },
}

pub enum Event {
    /// The serial worker has retired all requests made with the previous key.
    KeyChanged,
    /// One feed fetch's result; `mode` says what it does to the bundle.
    Feed {
        /// Cache key: a symbol / `MARKET_KEY` / `TRENDING_KEY` for news,
        /// `ins:SYM` for insider — the same key `App` tracks inflight and
        /// errors under.
        key: String,
        articles: Vec<Article>,
        /// Side payload of the head fetch; pages never refetch it.
        side: Option<FeedPayload>,
        next_cursor: Option<String>,
        /// Replace the bundle, extend it with an older page, or merge
        /// arrivals into its top (see `FeedMode`).
        mode: FeedMode,
        /// The score filter this fetch carried; None for feeds without one
        /// (trending, insider). The bundle records it so a changed setting
        /// marks the cached feed stale.
        min_relevance: Option<u8>,
        /// The category filter this fetch carried, recorded for the same
        /// reason; every category for feeds that cannot filter on it.
        categories: Categories,
    },
    /// A load-more failed. The shown feed stays; `gated` marks the archive
    /// horizon (terminal for this feed, offer an upgrade instead of a retry).
    PageError {
        key: String,
        error: String,
        gated: bool,
    },
    /// A background delta poll failed for a reason that is not the cursor:
    /// the shown feed stays untouched and the app stops polling it until the
    /// reader retries, because nothing here ever retries by itself.
    PollError { key: String, error: String },
    /// A background delta poll came back with the cursor rejected (400) or
    /// past the plan's archive horizon (403). Neither is worth showing: drop
    /// the poll position and the next poll starts a fresh one.
    PollReprime { key: String },
    /// One ticker's earnings reads. Boxed: a single read runs to ~16 KB and
    /// this rides the same channel as every price tick.
    Earnings {
        /// `earn:SYM`, the key the app tracks inflight and errors under.
        key: String,
        data: Box<TickerEarnings>,
    },
    /// A successful macro window, including the UTC date bounds requested.
    Calendar {
        events: Vec<CalendarEvent>,
        from: String,
        to: String,
    },
    /// Stop the watchlist date sweep after an account-wide access/limit error.
    CalendarBlocked { key: String, error: String },
    ReportCalendar {
        request: ReportRequest,
        result: Result<Vec<ReportEvent>, String>,
        blocked: bool,
    },
    /// `key` matches the cache key of the fetch that failed.
    Error { key: String, error: String },
}

/// Rollup fetched alongside a feed's head page. The trades bundle is boxed:
/// it dwarfs the sentiment rollup, and the payload travels inside every
/// `Event::Feed`.
pub enum FeedPayload {
    Sentiment(SentimentSummary),
    Insider(Box<InsiderTrades>),
}

/// Cache key for a symbol-scoped or market-wide news fetch.
pub fn news_key(symbol: Option<&str>) -> String {
    symbol.map_or_else(|| MARKET_KEY.to_string(), str::to_string)
}

/// Cache key for an insider fetch (kept distinct from news keys).
pub fn insider_key(symbol: &str) -> String {
    format!("ins:{symbol}")
}

/// Cache key for a ticker's earnings reads (kept distinct from feed keys).
pub fn earnings_key(symbol: &str) -> String {
    format!("earn:{symbol}")
}

/// Cache key for the macro calendar. It is the one payload here that is not
/// scoped to a ticker: one window covers the whole market.
pub const CALENDAR_KEY: &str = "~calendar";

/// How far ahead the calendar window reaches. Long enough to always hold the
/// next CPI and FOMC, short enough to stay one small response.
pub const CALENDAR_DAYS: i64 = 45;
pub const CALENDAR_LOOKBACK_DAYS: i64 = 7;

/// Which paginated feed a page fetch targets, each with its filters.
enum Feed<'a> {
    News(Option<&'a str>, NewsFilters),
    Insider(&'a str, Option<u8>),
}

/// Fetch one feed page, self-tuning the page size to the key's tier.
/// `PAGE_SIZE` (20) is what every tier allows and is used everywhere else.
/// `page50`: None = tier unknown, probe 50 on the first explicit paging
/// request (one extra request per session for non-Pro keys, and only when
/// the user asked for more); Some(true) = Pro confirmed, 50 everywhere;
/// Some(false) = 50 rejected once, stay on `PAGE_SIZE`.
async fn fetch_feed_page(
    client: &Client,
    feed: Feed<'_>,
    cursor: Option<&str>,
    sort: Sort,
    page50: &mut Option<bool>,
) -> Result<NewsPage> {
    // The probe spends a 400 on a non-Pro key, so it stays on the path the
    // user asked for: an explicit load-more of the published feed. A delta
    // poll never probes; it only takes 50 on a tier already known to be Pro.
    let try50 = matches!(*page50, Some(true))
        || (page50.is_none() && cursor.is_some() && sort == Sort::Published);
    if try50 {
        let result = match &feed {
            Feed::News(symbol, filters) => {
                client.news(*symbol, cursor, Some(50), *filters, 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);
            }
            // The Pro-only page size bounces with a 400; fall back silently.
            Err(e) if page50.is_none() && format!("{e:#}").contains("API 400") => {
                *page50 = Some(false);
            }
            Err(e) => return Err(e),
        }
    }
    match &feed {
        Feed::News(symbol, filters) => {
            client
                .news(*symbol, cursor, Some(PAGE_SIZE), *filters, sort)
                .await
        }
        Feed::Insider(symbol, min) => {
            client
                .insider_news(symbol, cursor, Some(PAGE_SIZE), *min, sort)
                .await
        }
    }
}

/// Error event for a fetch, by what the fetch was: a load-more degrades to a
/// non-destructive `PageError`, a background delta poll to `PollError` (or to
/// a silent reprime when the cursor it carried is what the server rejected),
/// and an initial fetch replaces the view via `Error`.
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 }
        }
        // Only a poll carrying a cursor can have its cursor rejected, so the
        // reprime it asks for (a poll with no cursor) cannot loop back here.
        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 },
    }
}

/// Whether a delta poll failed over the cursor it carried: a 400, which is
/// how the server answers a cursor it cannot read and one replayed into the
/// other sort mode (it never silently restarts), or the archive gate, which
/// in delta mode means the position aged past the plan's horizon rather than
/// the reader paging too deep.
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());
    // Whether this key may request page_size=50 (Pro); learned per session.
    let mut page50: Option<bool> = None;
    let mut last_report_start: Option<tokio::time::Instant> = None;
    while let Some(cmd) = cmds.recv().await {
        match cmd {
            Cmd::SetKey(key) => {
                client = key.and_then(|k| Client::new(k).ok());
                page50 = None;
                if tx.send(SourceEvent::Alphai(Event::KeyChanged)).is_err() {
                    return;
                }
            }
            Cmd::FetchNews {
                symbol,
                cursor,
                min_relevance,
                categories,
                collapse,
                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 filters = NewsFilters {
                    min_relevance,
                    categories,
                    collapse,
                };
                // Sentiment is a nice-to-have on the initial symbol fetch:
                // its failure must not blank the news list, so it degrades
                // to None. Pages and delta polls never refetch it.
                let (page, sentiment) = match (&symbol, mode) {
                    (Some(s), FeedMode::Replace) => {
                        let (p, senti) = tokio::join!(
                            fetch_feed_page(
                                client,
                                Feed::News(Some(s), filters),
                                None,
                                sort,
                                &mut page50,
                            ),
                            client.sentiment(s)
                        );
                        (p, senti.ok())
                    }
                    _ => (
                        fetch_feed_page(
                            client,
                            Feed::News(symbol.as_deref(), filters),
                            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,
                        categories,
                    },
                    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 {
                    // A head-only feed: no cursor means paging never starts.
                    Ok(articles) => Event::Feed {
                        key,
                        articles,
                        side: None,
                        next_cursor: None,
                        mode: FeedMode::Replace,
                        min_relevance: None,
                        categories: Categories::default(),
                    },
                    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,
                        categories: Categories::default(),
                    },
                    Err(e) => error_event(key, e, mode, cursor.as_deref()),
                };
                if tx.send(SourceEvent::Alphai(event)).is_err() {
                    return;
                }
            }
            Cmd::FetchEarnings {
                symbol,
                cache_symbol,
            } => {
                let key = earnings_key(&cache_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),
                    },
                    // A string no listing owns (crypto, a typo) is a state,
                    // not a failure: reporting it as an error would offer a
                    // retry that can never succeed.
                    Err(e) if is_unknown_symbol(&format!("{e:#}")) => Event::Earnings {
                        key,
                        data: Box::new(TickerEarnings {
                            ticker: symbol,
                            unknown: true,
                            ..Default::default()
                        }),
                    },
                    Err(e) => calendar_error(key, e),
                };
                if tx.send(SourceEvent::Alphai(event)).is_err() {
                    return;
                }
            }
            Cmd::FetchCalendar { from, to } => {
                let key = CALENDAR_KEY.to_string();
                let Some(client) = &client else {
                    send_error(&tx, key, "no AlphAI API key configured");
                    continue;
                };
                let event = match client.calendar(&from, &to).await {
                    Ok(events) => Event::Calendar { events, from, to },
                    Err(e) => calendar_error(key, e),
                };
                if tx.send(SourceEvent::Alphai(event)).is_err() {
                    return;
                }
            }
            Cmd::FetchReportCalendar(request) => {
                if let Some(at) = last_report_start {
                    tokio::time::sleep_until(at + Duration::from_secs(4)).await;
                }
                last_report_start = Some(tokio::time::Instant::now());
                let result = match &client {
                    Some(client) => client.earnings_calendar(&request).await,
                    None => Err(anyhow::anyhow!("no AlphAI API key configured")),
                };
                let blocked = result
                    .as_ref()
                    .err()
                    .and_then(|e| e.downcast_ref::<ApiError>())
                    .is_some_and(|e| matches!(e.status.as_u16(), 401 | 403 | 429));
                let event = Event::ReportCalendar {
                    request,
                    result: result.map_err(|e| format!("{e:#}")),
                    blocked,
                };
                if tx.send(SourceEvent::Alphai(event)).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(),
    }));
}

// ---------------------------------------------------------------------------
// API shapes (tolerant subset of the OpenAPI schema at alphai.io/api/schema/).

#[derive(Deserialize)]
pub struct NewsPage {
    #[serde(default)]
    pub results: Vec<Article>,
    /// Cursor for the next (older) page; None at the end of the feed.
    #[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,
    /// Stable identity of the story, even when its representative changes.
    #[serde(default)]
    pub story_id: Option<String>,
    /// Story-collapse only: distinct outlets covering this story. None when
    /// grouping is off and on rows predating the field.
    #[serde(default)]
    pub sources_count: Option<i64>,
    /// Insider feed only: the structured Form 4 event behind this row.
    /// None on news rows and on insider rows without transaction data.
    #[serde(default)]
    pub insider: Option<InsiderEvent>,
}

/// Structured SEC Form 4 event on an insider-feed row: the aggregate of the
/// filing's whole transaction group (a 10b5-1 ladder is one event: shares and
/// value are summed, the price is value-weighted). Replaces deriving the
/// trade side from the templated headline.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InsiderEvent {
    /// "buy" (code P) / "sell" (S) / "other" — note code D (sale back to the
    /// issuer, a buyback) is "other", not a market disposition.
    #[serde(default)]
    pub side: Option<String>,
    #[serde(default)]
    pub transaction_code: Option<String>,
    /// Decimal string, like the money fields below (both nullable when the
    /// filing prices no tranche).
    #[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,
    /// "YYYY-MM-DD".
    #[serde(default)]
    pub transaction_date: Option<String>,
}

impl InsiderEvent {
    /// The reporting owner's role: their title when the filing carries one,
    /// otherwise derived from the role flags.
    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,
    /// SEC Form 4 rows only: "direct" or "indirect" holding pool.
    #[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>,
    /// 1-10 novelty of the information; 0 marks rows enriched before the
    /// field existed and must render as unknown, not as a zero.
    #[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))
    }

    /// Compact age like "35m" / "4h" / "3d"; empty when unparsable.
    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),
        }
    }

    /// The full AI impact analysis for one ticker (case-insensitive).
    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()
    }

    /// The AI sentiment call for one ticker: "positive" / "neutral" / "negative".
    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()
    }

    /// Displayable novelty (1-10); None when missing or when the API sends
    /// the 0 sentinel for rows enriched before the field existed.
    pub fn novelty(&self) -> Option<i64> {
        self.trading_value()?.information_novelty.filter(|&n| n > 0)
    }

    /// Displayable outlet count; None when absent or when only one outlet
    /// carries the story (nothing worth badging).
    pub fn sources_badge(&self) -> Option<i64> {
        self.sources_count.filter(|&n| n > 1)
    }

    /// The article's page on alphai.io.
    pub fn alphai_url(&self) -> Option<String> {
        article_url_for(&self.original.uid, &self.original.title, self.published())
    }
}

/// The article page for one uid: `/news/article/{MM-DD}/{uid}/{slug}`. None
/// when there is no uid, no timestamp, or the title slugifies to nothing;
/// callers fall back to the original source URL. Free-standing so an
/// earnings read can build its link without a feed row.
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}"))
}

/// Whether a feed row is the earnings filing itself rather than coverage of
/// it. Only the filing carries a read: it comes from SEC EDGAR (an 8-K item
/// 2.02, or a foreign private issuer's 6-K) and lands in the earnings
/// category, while reprints of the same quarter come from news outlets.
pub fn is_earnings_filing(a: &Article) -> bool {
    a.original.source_domain == "sec.gov" && a.enrichment.category.as_deref() == Some("earnings")
}

/// Mirror of the site's slugify: keep ASCII word chars, turn whitespace and
/// hyphen runs into single hyphens, drop the rest. An imperfect match is
/// harmless: the article page resolves by uid and 301s to the canonical slug.
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,
}

/// Form 4 chart bundle from `/api/symbols/{t}/insider-trades/`. One filing's
/// tranche group is one event throughout; money is decimal strings like the
/// feed. Everything is optional-tolerant: the view renders whatever arrived
/// and skips the rest.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InsiderTrades {
    /// Earliest recorded fill ("YYYY-MM-DD"); the chart window never claims
    /// history from before it.
    #[serde(default)]
    pub coverage_start: Option<String>,
    #[serde(default)]
    pub summary: Option<TradesSummary>,
    /// Monday-keyed weekly buy/sell dollar buckets, zero-filled up to the
    /// current week, capped to the trailing 12 months.
    #[serde(default)]
    pub series_weekly: Vec<WeekBucket>,
    /// Every event of the trailing 365 days, both sides, unfiltered by the
    /// feed's relevance score: the chart is always the full picture.
    #[serde(default)]
    pub chart_events: Vec<TradeEvent>,
}

/// The subset of the endpoint's summary the TUI shows (the head line and
/// the top reporters); the 3m and all-time windows ride along in the JSON
/// and are simply not parsed.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct TradesSummary {
    #[serde(default)]
    pub last_12m: Option<TradesWindow>,
    /// Most active reporters of the last 12 months, at most five.
    #[serde(default)]
    pub top_insiders: Vec<TradeTopInsider>,
}

/// One trailing-window rollup. Counts are grouped events (not tranches);
/// a value is None when the window has no priced events on that side.
#[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 {
    /// Monday of the bucket, "YYYY-MM-DD".
    #[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>,
}

/// One chart event, trimmed to what the panel plots (who and how much per
/// share stay on the feed row's structured block). Unlike the feed's
/// `insider.side` (where code D maps to "other"), this surface counts D — a
/// sale back to the issuer — as "sell"; `transaction_code` tells the two
/// apart (P buy / S sale / D to issuer).
#[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>,
    /// Fills folded into this event (a 10b5-1 ladder files many).
    #[serde(default)]
    pub tranche_count: Option<i64>,
    /// Percent of the pre-event stake this event moved; sells negative.
    #[serde(default)]
    pub stake_change_pct: Option<String>,
    #[serde(default)]
    pub is_10b5_1: bool,
    #[serde(default)]
    pub late_filing: bool,
    /// Last fill of the group, "YYYY-MM-DD".
    #[serde(default)]
    pub transaction_date: Option<String>,
    /// Uid of the feed article behind this event; joins the chart to the
    /// list (selection highlight, stake/tranches in the detail pane).
    #[serde(default)]
    pub news_uid: Option<String>,
}

// ---------------------------------------------------------------------------
// Earnings reads: AlphAI's structured analysis of an earnings filing, and
// the macro calendar. Neither paginates.

/// One ticker's earnings reads plus the date of its next report, from
/// `GET /api/symbols/{ticker}/earnings/` — a single request per ticker.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct TickerEarnings {
    #[serde(default)]
    pub ticker: String,
    /// Newest first, capped at 20 by the server. An empty list is a normal
    /// answer, not an error: no read has been published for this ticker.
    #[serde(default)]
    pub reports: Vec<EarningsRead>,
    /// Next report date marked confirmed by the source (America/New_York).
    /// Missing means no date is available, not that the company will not report.
    #[serde(default)]
    pub next_report_date: Option<String>,
    /// Set by the fetcher when the API owns no such symbol (404), never on
    /// the wire. Terminal: the view says so instead of offering a retry.
    #[serde(skip)]
    pub unknown: bool,
}

impl TickerEarnings {
    pub fn next_report_day(&self) -> Option<NaiveDate> {
        let day = self.next_report_date.as_deref()?.trim();
        let parsed = NaiveDate::parse_from_str(day, "%Y-%m-%d").ok()?;
        (parsed.to_string() == day).then_some(parsed)
    }

    /// The newest read, the one the view opens on.
    pub fn latest(&self) -> Option<&EarningsRead> {
        self.reports.first()
    }
}

/// One published read in a ticker's history.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct EarningsRead {
    /// Article uid: the same read is served inline by `/api/news/{uid}/`, and
    /// it joins a read to the filing's row in the news feed.
    #[serde(default)]
    pub uid: String,
    /// The share class the filing was actually made under, which is not
    /// always the one asked for: a GOOGL request carries reads filed under
    /// GOOG.
    #[serde(default)]
    pub ticker: String,
    #[serde(default)]
    pub fiscal_period: String,
    #[serde(default)]
    pub time_published: String,
    #[serde(default)]
    pub title: String,
    /// `sec_form8k` for a US filer's item 2.02, `sec_form6k` for a foreign
    /// private issuer's earnings release.
    #[serde(default)]
    pub source_type: String,
    /// The read itself. Reached through `report()` so no call site has to
    /// read `read.analysis.analysis`.
    #[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))
    }

    /// The filing form, for the feed cell and the view header.
    pub fn form(&self) -> &'static str {
        if self.source_type == "sec_form6k" {
            "6-K"
        } else {
            "8-K"
        }
    }

    /// The read's own article page on alphai.io.
    pub fn alphai_url(&self) -> Option<String> {
        article_url_for(&self.uid, &self.title, self.filed())
    }
}

/// AlphAI's structured read of one earnings release. Every figure is a
/// string copied verbatim from the filing (the server checks each one
/// against the filing text before publishing), so the client shortens units
/// but never recomputes a number: see `short_metric`.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct EarningsReport {
    #[serde(default)]
    pub company: String,
    #[serde(default)]
    pub ticker: String,
    /// Free text as the filing words it: "Second Quarter Fiscal 2027",
    /// "fiscal 2026 third quarter", "six months ended June 30, 2026".
    #[serde(default)]
    pub fiscal_period: String,
    #[serde(default)]
    pub period_end: Option<String>,
    #[serde(default)]
    pub headline: String,
    /// strong / solid / mixed / weak. Kept a string: an unknown value must
    /// render, not break the view.
    #[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>,
    /// Several paragraphs, separated by blank lines.
    #[serde(default)]
    pub analysis: String,
    /// What the filing did NOT state, named rather than guessed.
    #[serde(default)]
    pub missing_items: Vec<String>,
    #[serde(default)]
    pub numbers_verified_from_document: bool,
}

impl EarningsReport {
    /// The multi-paragraph narrative, by a name that is not `analysis`.
    pub fn narrative(&self) -> &str {
        &self.analysis
    }
}

/// One line of the filing's own numbers. Comparisons are None when the
/// filing did not state them (the guard nulls anything it cannot verify).
#[derive(Clone, Debug, Default, Deserialize)]
pub struct KeyMetric {
    #[serde(default)]
    pub name: String,
    #[serde(default)]
    pub value: String,
    /// GAAP / non-GAAP / other.
    #[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,
}

/// The company's own outlook, when the release gave one.
#[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>,
}

/// A reported figure against the company's own prior outlook.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct GuidanceCheck {
    #[serde(default)]
    pub metric: String,
    #[serde(default)]
    pub prior_guidance: String,
    #[serde(default)]
    pub actual: String,
    /// above / in line / below / n/a.
    #[serde(default)]
    pub verdict: String,
}

/// Management, verbatim. Named for the price `Quote` in `crate::domain`.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct SpeakerQuote {
    #[serde(default)]
    pub speaker: String,
    #[serde(default)]
    pub role: Option<String>,
    #[serde(default)]
    pub text: String,
}

/// One scheduled US macro release, from `GET /api/calendar/`.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct CalendarEvent {
    #[serde(default, deserialize_with = "calendar_string")]
    pub uid: String,
    #[serde(default, deserialize_with = "calendar_string")]
    pub event_key: String,
    #[serde(default, deserialize_with = "calendar_string")]
    pub reference_period: String,
    #[serde(default)]
    pub release_stage: Option<String>,
    #[serde(default)]
    pub source_url: Option<String>,
    #[serde(default, deserialize_with = "calendar_string")]
    pub title: String,
    #[serde(default, deserialize_with = "calendar_string")]
    pub scheduled_at: String,
    /// upcoming / elapsed. Says only that the moment passed, never that the
    /// agency published.
    #[serde(default, deserialize_with = "calendar_string")]
    pub phase: String,
    /// scheduled / postponed / cancelled. Read this before `phase`.
    #[serde(default, deserialize_with = "calendar_string")]
    pub schedule_status: String,
    /// official (printed on the agency's schedule) / inferred (derived from
    /// the documented cadence). Shown, not hidden.
    #[serde(default, deserialize_with = "calendar_string")]
    pub schedule_basis: String,
    /// high / medium / low.
    #[serde(default, deserialize_with = "calendar_string")]
    pub importance: String,
    /// FOMC decisions only: the press conference.
    #[serde(default)]
    pub press_conference_at: Option<String>,
    /// FOMC decisions only: the meeting carries a Summary of Economic
    /// Projections (the dot plot).
    #[serde(default)]
    pub has_sep: bool,
}

// Some schedule fields may be absent on postponed/undated events. Null
// carries the same lack of information as a missing string, not a bad window.
fn calendar_string<'de, D: serde::Deserializer<'de>>(
    d: D,
) -> std::result::Result<String, D::Error> {
    Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
}

impl CalendarEvent {
    pub fn short_name(&self) -> &str {
        if self.event_key == "fomc_decision" {
            return "FOMC";
        }
        let title = self.title.split(" (").next().unwrap_or("").trim();
        // Use the published title for series whose event_key is not known.
        // These are display abbreviations, never assumptions about a wire enum.
        for (prefix, short) in [
            ("FOMC minutes", "minutes"),
            ("CPI", "CPI"),
            ("PPI", "PPI"),
            ("Nonfarm payrolls", "jobs"),
            ("Employment Situation", "jobs"),
            ("GDP", "GDP"),
            ("PCE", "PCE"),
            ("Retail sales", "retail"),
            ("Initial jobless claims", "claims"),
            ("JOLTS", "JOLTS"),
        ] {
            if title.starts_with(prefix) {
                return short;
            }
        }
        if title.is_empty() {
            "Macro event"
        } else {
            title
        }
    }

    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>,
}

/// "25000.0000" (API decimal string) -> "25,000"; fractional shares round.
/// Unparsable input passes through untouched, like `fmt_usd`.
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 }
}

/// Shorten a filing figure for a narrow column: units only, never the
/// number itself. "$96,221 million" becomes "$96,221M" and never "$96.2B" —
/// a rounded figure is a new number, and every figure here is quoted from
/// the filing precisely so it is not one.
pub fn short_metric(value: &str) -> String {
    let mut trimmed = value.trim();
    // Balance-sheet lines carry the date they were measured on; the column
    // header already says which period the column is.
    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
}

/// Phrases a filing appends to a change that the column header already
/// states. Dropping them shortens the cell without touching the figure.
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",
];

/// A change as the filing states it, with a leading `+` when it carries no
/// sign of its own ("18%" -> "+18%"). Only the sign is added: a filing that
/// wrote a decline wrote the minus itself.
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;
        }
    }
    // "up 16%" and "down 5%" are the filing's own words for a sign, so they
    // become one. The figure itself is never touched.
    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()
}

/// "Second Quarter Fiscal 2027" -> "Q2 FY27", for headers too narrow for the
/// filing's own wording. Anything that does not name a quarter and a year is
/// returned untouched: the wording is free text and inventing a period is
/// worse than a long one.
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(),
    }
}

/// "1234567.89" (API decimal string) -> "$1.2M"; sign kept in front.
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"));
    }

    /// Published requests must stay byte for byte what they were before delta
    /// mode existed: `published` is the server's default, so it goes on the
    /// wire as no parameter at all.
    #[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);
        // A delta page merges either way: the priming poll carries no cursor.
        assert_eq!(feed_mode(Sort::Ingested, None), FeedMode::Merge);
        assert_eq!(feed_mode(Sort::Ingested, Some("d1")), FeedMode::Merge);
    }

    /// A poll that carried a position and had it rejected reprimes silently;
    /// the same failure without a position is a real error, which is what
    /// keeps a reprime from looping into another reprime.
    #[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 { .. }
        ));
        // The published paths keep their old shapes.
        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() {
        // novelty 0 marks rows enriched before the field existed; a single
        // outlet is not worth a badge. Both must read as "unknown".
        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() {
        // /api/news/trending/ returns a bare array, not the paginated shape.
        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"));

        // Unpriced filings null the money fields; the block still parses.
        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"
        );

        // No uid (or a title with no ASCII word chars) -> fall back to original.
        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() {
        // Mirrors the live /api/symbols/{t}/insider-trades/ shape: summary
        // windows, Monday-keyed weekly buckets, per-event chart rows with
        // stake/tranche extras, decimals as strings.
        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"));
        // The unpriced code-D event still parses; sale-to-issuer stays
        // distinguishable via the code even though side says "sell" here.
        let d = &t.chart_events[1];
        assert_eq!(d.transaction_code.as_deref(), Some("D"));
        assert_eq!(d.total_value_usd, None);

        // A ticker with no coverage serves nulls and empty arrays.
        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]
    async fn news_grouping_is_sent_for_both_scopes_pages_and_delta() {
        use tokio::io::{AsyncReadExt, AsyncWriteExt};
        use tokio::net::TcpListener;

        for symbol in [Some("CRWV"), None] {
            for collapse in [false, true] {
                for (sort, cursor) in [
                    (Sort::Published, None),
                    (Sort::Published, Some("older")),
                    (Sort::Ingested, Some("delta")),
                ] {
                    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
                    let base = format!("http://{}", listener.local_addr().unwrap());
                    let server = tokio::spawn(async move {
                        let (mut socket, _) = listener.accept().await.unwrap();
                        let mut request = Vec::new();
                        let mut buffer = [0; 2048];
                        while !request.windows(4).any(|w| w == b"\r\n\r\n") {
                            let n = socket.read(&mut buffer).await.unwrap();
                            assert!(n > 0);
                            request.extend_from_slice(&buffer[..n]);
                        }
                        let body = r#"{"results":[],"next_cursor":null}"#;
                        socket.write_all(format!(
                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
                            body.len()
                        ).as_bytes()).await.unwrap();
                        String::from_utf8(request).unwrap()
                    });
                    let client = Client {
                        http: reqwest::Client::new(),
                        base,
                        key: "test".into(),
                    };
                    let filters = NewsFilters {
                        min_relevance: Some(7),
                        categories: Categories::default(),
                        collapse,
                    };
                    // Use the worker's page helper too: confirmed Pro pages
                    // exercise its 50-row branch; other cases use 20.
                    let mut page50 = cursor.map(|_| true);
                    fetch_feed_page(
                        &client,
                        Feed::News(symbol, filters),
                        cursor,
                        sort,
                        &mut page50,
                    )
                    .await
                    .unwrap();
                    let request = server.await.unwrap();
                    let path = request.split_whitespace().nth(1).unwrap();
                    let url = reqwest::Url::parse(&format!("http://localhost{path}")).unwrap();
                    let params: std::collections::HashMap<_, _> =
                        url.query_pairs().into_owned().collect();
                    assert_eq!(url.path(), "/api/news/");
                    assert_eq!(params.get("symbol").map(String::as_str), symbol);
                    assert_eq!(
                        params.get("collapse").map(String::as_str),
                        collapse.then_some("story")
                    );
                    assert_eq!(params.get("cursor").map(String::as_str), cursor);
                    assert_eq!(params.get("sort").map(String::as_str), sort.param());
                    assert_eq!(params.get("min_relevance").map(String::as_str), Some("7"));
                }
            }
        }
    }

    /// Live end-to-end check against the real API (14 requests).
    /// Run: ALPHAI_API_KEY=ak_live_… cargo test live_api -- --ignored
    #[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();
        // min_relevance 4 mirrors the server default, so the filter param is
        // exercised without changing what the feed returns.
        // PAGE_SIZE is what the app actually sends, and it rides on the API
        // allowing it without a Pro key: ask for it here so a tightened cap
        // surfaces as a failing smoke test rather than a 400 for every free
        // user.
        let news = client
            .news(
                Some("NVDA"),
                None,
                Some(PAGE_SIZE),
                NewsFilters {
                    min_relevance: Some(4),
                    categories: Categories::default(),
                    collapse: true,
                },
                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);
        // The feed is deeper than one page, so the cursor must be present
        // and must fetch an older second page.
        let cursor = news.next_cursor.expect("no next_cursor on page 1");
        let page2 = client
            .news(
                Some("NVDA"),
                Some(&cursor),
                None,
                NewsFilters {
                    min_relevance: Some(4),
                    categories: Categories::default(),
                    collapse: true,
                },
                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");
        // Every Form 4 row is backed by transaction data, so the structured
        // block must be present on the live 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");

        // The earnings surface: a ticker with a read, a ticker without one
        // (an empty list is a normal 200), and a string no listing owns.
        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:#}"
        );

        // The macro calendar: one window, one request, always populated.
        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()));

        // Delta mode, the whole of the app's refresh path. The priming page
        // carries rows and a position; replaying that position returns only
        // what has arrived since, which is usually nothing, and still hands
        // back a position (there is no terminal page in this mode).
        let prime = client
            .news(
                Some("NVDA"),
                None,
                Some(PAGE_SIZE),
                NewsFilters {
                    min_relevance: Some(4),
                    categories: Categories::default(),
                    collapse: true,
                },
                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,
                NewsFilters {
                    min_relevance: Some(4),
                    categories: Categories::default(),
                    collapse: true,
                },
                Sort::Ingested,
            )
            .await
            .unwrap();
        assert!(
            delta.next_cursor.is_some(),
            "delta mode dropped the position"
        );
        // The two cursor families must stay mutually unreadable: the app's
        // reprime path exists because this is a 400 and not a silent restart
        // into a different range of the feed.
        let crossed = match client
            .news(
                Some("NVDA"),
                Some(&cursor),
                None,
                NewsFilters {
                    min_relevance: Some(4),
                    categories: Categories::default(),
                    collapse: true,
                },
                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"
        );
        // The insider feed answers delta mode too (it did not in July 2026),
        // which is where it matters most: a Form 4 is filed days after its
        // trade and enters the feed below the published head.
        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"
        );
    }

    /// Trimmed to four metrics; the shape is the live payload's.
    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/")
        );
    }

    /// A foreign private issuer's 6-K: no segments, no guidance, no prior
    /// periods, figures in the filing's own currency. The renderer has to
    /// live on this shape as happily as on the full one.
    #[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);
    }

    /// The most common answer of all: covered, but nothing published yet.
    #[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());
    }

    /// Units shorten, figures never change. A rounded number is a new
    /// number, and the whole point of the layer is that every figure is the
    /// filing's own.
    #[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");
        // Nothing to shorten: a half-year period keeps the filing's wording
        // rather than being forced into a quarter it does not name.
        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"));
    }

    /// The feed cannot say whether a row has a read, so the client tells the
    /// filing apart from coverage of it by where the row came from.
    #[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));
    }

    /// 21 and above is a Pro-only page size: raising `PAGE_SIZE` past 20
    /// would turn every free key's feed into a 400.
    #[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), "");
    }
}

#[cfg(test)]
mod calendar_tests;