repos

Phase 25: Earnings dates

aac84aee by Isaac Bythewood · 1 month ago

Phase 25: Earnings dates

Earnings section between Key stats and Stock health on the stock symbol
page (Most recent + Next expected, plus the last four past dates), and
small ink-faint dot pips on the candlestick chart at each past earnings
date. Next-date source is Yahoo's quoteSummary.calendarEvents primary
with a cadence estimate from past 8-K item-2.02 dates as the fallback.

- Migration 0011 adds symbols.next_earnings_at + earnings_synced_at
- YahooProvider::earnings_calendar (v10 quoteSummary, calendarEvents)
- compute::next_earnings_estimate (median-gap projection, 3 unit tests)
- New `earnings_calendar` scheduler job on the existing yahoo guard,
  monthly + roll-on-pass; backfill_symbol pulls it for new stocks
- Symbol route build_earnings + Earnings template section + .earn SCSS
- /api/symbols/{ticker}/history carries past dates; chart.js draws v5
  createSeriesMarkers aboveBar circles in --ink at 55% opacity
- /health lists the new earnings_calendar job

Stocks only; ETFs / indexes / futures hide the section cleanly. Past
dates ride for free off the 8-K item-2.02 filings Phase 14 stores in
`filings.items`. No new endpoint guard.
modified PLAN.md
@@ -32,8 +32,27 @@ and resume cleanly from this file alone, keeping token use low._Last updated: 2026-05-23_**Current phase: Phase 17 (stock health read) complete, verified,and deployed to production 2026-05-23 (commit `8a16b14`).** A single non-advice "health" read**Current phase: Phase 25 (earnings dates) complete, verifiedlocally — not yet deployed.** A new Earnings section on the stocksymbol page between Key stats and Stock health, plus small ink-dotpips on the candlestick chart at each past earnings date. Two surfaces:(1) the page section carries Most recent + Next expected as a pairedheadline (with "23 days ago" / "in 68 days · estimated from cadence"sub-lines) and a list of the last four earnings dates. The next-expecteddate is Yahoo's `quoteSummary.calendarEvents` when available and acadence-estimate fallback from the past 8-K item-2.02 dates when not.(2) The candle chart now carries small ink-faint circle pips above eachmatching bar (Paper Ledger ink palette — wayfinding, not a valueverdict; the candles still own green/red). Migration `0011` adds twocolumns to `symbols` (`next_earnings_at`, `earnings_synced_at`); a new`earnings_calendar` scheduler job on the existing `yahoo``EndpointGuard` sweeps stocks monthly (one request per stock to`v10/finance/quoteSummary?modules=calendarEvents`). Past dates ridefor free off the 8-K item-2.02 filings the Phase 14 `items` columnalready stores. Pure derivation otherwise — no new endpoint guard, noSEC sync needed beyond what Phase 14 ships. **Phase 17 (stock healthread) complete, verified, and deployed to production 2026-05-23(commit `8a16b14`).** A single non-advice "health" readper stock layered over the Phase 20 strength + trajectory composite: itfolds in a leadership-stability signal read off the recent 8-K item-5.02change count (Phase 14). Industry context (Phase 15) was intentionally
@@ -126,6 +145,83 @@ backlog as Phase 19. The `watchlists` / `watchlist_items` tables stay in theschema, unused for now.**Done**- **Phase 25 earnings dates.** Complete, verified locally — not yet  deployed. Past earnings dates come for free from the 8-K item-2.02  filings Phase 14 already stores in `filings.items`; the next-expected  date is fetched fresh from Yahoo's `quoteSummary.calendarEvents`  module with a cadence-estimate fallback when Yahoo carries nothing.  Stocks only — ETFs / indexes / futures hide the section cleanly.  - **Migration `0011`** adds two columns to `symbols`:    `next_earnings_at` (epoch-ms, NULL when Yahoo has no upcoming    date or the sync has not run yet) and `earnings_synced_at`    (epoch-ms of the last successful sweep). No new table; the past    dates ride on the existing `filings.items LIKE '%2.02%'` SELECT.  - **Yahoo provider** got `earnings_calendar(ticker)` — one request    to `v10/finance/quoteSummary?modules=calendarEvents`, parses    `earnings.earningsDate[0]` (the array carries 1 or 2 entries —    confirmed date or confirmed/estimated pair). Filters to future    dates only (Yahoo sometimes returns the just-passed print before    rolling forward). Same defensive RateLimited surfacing as the    Phase 28 `fund_metadata` path (429 / 503 / 401 / 403 all trip the    `yahoo` `EndpointGuard`).  - **Compute** added `next_earnings_estimate(newest_first_dates)` —    a pure helper that reads the median gap between the last up-to-4    earnings dates and projects forward. Clamped into 60–200 days so    a degenerate same-day-correction gap (a 2.02 press-release + a    same-day follow-up) doesn't yield a date in the next week.    Returns `None` on fewer than 2 priors. Three unit tests    (quarterly cadence, too-few priors, same-day corrections).  - **Scheduler** has a new `earnings_calendar` section on the    existing `yahoo` `EndpointGuard`, monthly staleness OR re-run    once the stored `next_earnings_at` passes (a missed roll-forward    never sits stale). Brought forward to the first tick on boot the    same way `sec` / `dividends` / `fund_metadata` are. Logs to    `fetch_log` + `data_status`; surfaces on `/health` automatically.    `scheduler::backfill_symbol` pulls a new stock's earnings    calendar inside the add-symbol request, mirroring Phase 21's    intent.  - **Symbol route** has a new `build_earnings(pool, ticker,    next_earnings_at, earnings_synced_at)` that loads past dates from    8-K item-2.02 filings (newest first), computes the "days ago" /    "days from now" deltas off `chrono::Utc::now()`, picks the    next-date source (Yahoo primary, estimate fallback), and caps    the past-dates list at 4 (one trailing year of a quarterly    cadence) per the design pass. Returns `None` when there's    nothing to show — no past dates *and* no next date — so the    template hides the section cleanly.  - **History API** (`/api/symbols/{ticker}/history`) gained an    optional `earnings: [{time}, ...]` field for stocks, carrying    all past 8-K item-2.02 dates. The chart filters to dates that    match a visible candle (lightweight-charts ignores markers whose    time does not match), sorts ascending (the v5 API requires it),    and draws each as a small `aboveBar` `circle` marker via    `createSeriesMarkers`. Color `rgba(33,31,26,0.55)` — the warm    paper ink, never green / amber / red. The same exception the    Phase 8 indicator lines and the Phase 18 asset-mix bar take.  - **Symbol template** has a new "Earnings" section between Key    stats and Stock health: a 2-up paired headline (Most recent +    Next expected) on desktop that stacks on phone, plus a list of    the last four dates with days-ago captions, and a quiet    provenance line labelling the source.  - **Health page** lists the new `earnings_calendar` job between    `fund_metadata` and `dividends` (job_meta + job_rank).  - **SCSS** added a small `.earn` block to `symbol.scss` mirroring    `.div-panel` / `.div-pace` shape (`@include card` panel, eyebrow    captions, mono figures, hairline border per cell, 2-column at    520px) and an `.earn-row` history row mirroring `.div-row`.  - Verified: cargo + bun build clean; 3 new Phase 25 unit tests    pass alongside the 7 prior. `/api/symbols/AAPL/history?range=1Y`    returns an 11-entry `earnings` array (Apr 30, Jan 29, Oct 30,    Jul 31, ...). `/s/AAPL` renders the Earnings section with    "Apr 30 · 23 days ago" + "Jul 30 · in 68 days · estimated from    cadence" (Yahoo has not been hit yet on the dev box; the    cadence estimate handled the next-date fallback exactly as    designed) and four past-date rows. The candlestick chart at    1Y range shows four ink-faint dots above the Aug 1, Oct 30,    Jan 29, and Apr 30 candles. `/s/SPY` (ETF), `/s/^SPX` (index),    and `/s/GC=F` (future) hide the section cleanly. Desktop    (1280px) and phone (390px) both render with no horizontal    overflow and zero console errors.- **Phase 17 stock health read.** Complete, verified, and deployed to  production 2026-05-23 (commit `8a16b14`). A non-advice synthesis of the data this  app already carries (fundamentals + price/growth trajectory + leadership
@@ -1218,8 +1314,9 @@ feeds: iShares/BlackRock, Vanguard, ...) was captured 2026-05-22 from avibe-coding side note mid-Phase-28; see the decisions log.Phase 30 (top picks + backtest) is complete, verified, and deployed toproduction 2026-05-23 (commit `8ea9048`). Remaining post-MVP work isthe loose-ordered Phase 13, 15, 16, 17, 19, 25, 27, 29 backlog. Phase 26 (dividend payouts)production 2026-05-23 (commit `8ea9048`). Phase 25 (earnings dates) iscomplete and verified locally 2026-05-23 — not yet deployed. Remainingpost-MVP work is the loose-ordered Phase 13, 15, 19, 27, 29 backlog. Phase 26 (dividend payouts)is complete and deployed (commit `7608b06`); the MVP plus Phase 14,Phase 18, Phase 20, Phase 21, Phase 23 + 24, Phase 22 and Phase 26 are alllive at https://finance.bythewood.me. There is still no GitHub repo for
@@ -1636,7 +1733,20 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data).  which reads as a stray decimal point — the user noticed it on DELL. Replace  it with an em dash (`—`) or a similar unambiguous "no data" mark.- [ ] **Phase 25: Earnings dates.** (Captured 2026-05-22 as a vibe-coding- [x] **Phase 25: Earnings dates.** Complete and verified locally  2026-05-23 — see the Phase 25 entry in Status (Done list) and the  decisions log. Two surfaces: a new symbol-page section between Key  stats and Stock health (Most recent + Next expected as a paired  headline, plus the last four past dates), and small ink-faint dot  pips on the candlestick chart at each past earnings date. Next-date  source is Yahoo's `quoteSummary.calendarEvents` primary with a  cadence estimate from past 8-K item-2.02 dates as the fallback.  Migration `0011` adds `symbols.next_earnings_at` +  `symbols.earnings_synced_at`; a new `earnings_calendar` scheduler  section sweeps stocks monthly on the existing `yahoo`  `EndpointGuard`. Past dates come for free from the 8-K item-2.02  filings Phase 14 already stores. Original phase scope below.  (Captured 2026-05-22 as a vibe-coding  side note.) Surface a stock's earnings rhythm on the symbol page and the  chart. Pieces:  (1) A small section showing the most recent earnings date with "N days
@@ -3021,6 +3131,59 @@ finance/     sits above Fundamentals (the existing Phase 20 standing badge stays     in place, since it is specifically the per-ratio rollup and the new     panel is the broader synthesis).- **2026-05-23 — Phase 25 picked next; design Q&A settled four points.**  After Phase 17 deployed, the user picked Phase 25 (earnings dates)  over the remaining loose backlog (13 heat map, 15 industry trends,  27 backup providers, 29 issuer-direct ETF feeds). Four scoping  questions resolved before any code:  1. **Next-date source.** Yahoo's `quoteSummary.calendarEvents`     primary with a cadence-estimate fallback when Yahoo carries     nothing for the stock (uneven coverage on small caps). Hybrid     beats either alone — Yahoo is authoritative when present, the     cadence estimate covers what it does not.  2. **Page placement.** Right under Key stats — top-of-page,     before Stock health → Fundamentals → ... → Filings. Earnings     rhythm is "what's happening with this company now" and reads     alongside the live quote rather than buried near the filings.  3. **Chart pip style.** Small ink dot above each earnings bar via     lightweight-charts' v5 `createSeriesMarkers` API, `aboveBar`     `circle`, color `rgba(33,31,26,0.55)` — the Paper Ledger ink     palette, deliberately *not* green / amber / red so it reads as     wayfinding (same exception the Phase 8 indicator lines and the     Phase 18 asset-mix bar take). Picked over a vertical hairline     guide (too prominent) and an "E" letter glyph (slightly noisier).  4. **Past-dates depth.** Last 4 (one trailing year of a quarterly     cadence), matching the Phase 16 anomaly feed's 1-year window.     All past dates still ride the chart pip layer (filtered to the     visible candle range) so a 5Y view shows all of them.- **2026-05-23 — Phase 25 earnings dates shipped (local).** Yahoo  `quoteSummary` is now the source for a fourth concern beyond  quotes / dividends / fund metadata — its `calendarEvents` module.  Design calls made during the build: (1) **`earnings_calendar` is  an inherent `YahooProvider` method, not behind a trait** — same  single-source rationale as `dividends` / `fund_metadata` / `lookup`,  and Phase 27 (provider redundancy) is the right place to lift it if  a second source ever joins. (2) **Past dates come from `filings.items  LIKE '%2.02%'`** — Phase 14 already stores 8-K item codes, so no new  data source for the past timeline; one SELECT in the symbol route  feeds both the page list and the chart pip layer. (3) **Cadence  estimate clamps to 60–200 days** so a degenerate same-day gap (a  press release + a same-day follow-up 2.02 8-K) does not project a  date in the next week. (4) **Job re-runs once `next_earnings_at`  passes**, not only on monthly staleness — a stock whose date Yahoo  has not yet rolled forward is picked up the next tick after its  print lands, so the page is rarely showing a stale "in -2 days"  reading. (5) **The chart pip color reuses `--ink` at 55% opacity**  rather than introducing a new token: it sits naturally with the  warm paper world and matches the Phase 25 design Q&A's "deliberate  non-semantic" call. (6) **The `HistoryResponse` carries all past  dates** (not just the last 4); the client filters to dates that  match a visible candle, so a deep MAX range correctly shows every  past pip in scope, while 1M / 6M views only show the ones inside  their window. The cadence-estimate path was exercised on the local  dev box (Yahoo blanket-429s the WSL2 IP), and the page rendered  exactly as expected; Yahoo's calendar will land on the production  alpine box on the next sweep after deploy. Not yet deployed.- **2026-05-23 — Phase 17 stock health read shipped (local).** Pure  derivation on top of data the app already carries, no schema change  and no new network calls. The composite is fundamentals 0.55 +
modified frontend/static_src/symbol/scripts/chart.js
@@ -4,6 +4,7 @@ import {  LineSeries,  HistogramSeries,  ColorType,  createSeriesMarkers,} from "lightweight-charts";// Paper Ledger theme: ink figures and hairline rules on the warm paper
@@ -52,6 +53,12 @@ const RSI_INK = "#3f6f9c";const BENCH_INK = "#7a5237";const VOLUME_UP = "rgba(47,125,79,0.38)";const VOLUME_DOWN = "rgba(178,59,50,0.38)";// Phase 25: earnings-date markers. A small ink dot above each candle that// matches a past 8-K item-2.02 date. Same warm-paper ink-faint as the rest// of the Paper Ledger palette so it reads as wayfinding, not a value verdict// (the candles still own green/red and the indicator inks own the other// non-semantic palette).const EARNINGS_INK = "rgba(33,31,26,0.55)";/** `12.4` -> `+$12.40`, `-3` -> `-$3.00`. */function fmtMoney(n) {
@@ -129,6 +136,13 @@ export function initChart() {    visible: false,  });  // Earnings-date markers (Phase 25). Stocks only; the payload carries an  // `earnings` array of `YYYY-MM-DD` past dates that match candle times.  // Each draws a small ink dot above the matching bar. v5's  // createSeriesMarkers attaches to the candle series and is replaced  // wholesale on each setMarkers call.  const earningsMarkers = createSeriesMarkers(series, []);  let bars = []; // loaded candles, ascending by time  let latest = null; // last loaded payload, kept so RSI can attach on demand
@@ -346,6 +360,23 @@ export function initChart() {    if (benchBtn) benchBtn.hidden = bench.length === 0;    const benchOn = bench.length > 0 && (!benchBtn || benchBtn.classList.contains("is-active"));    benchmarkSeries.applyOptions({ visible: benchOn });    // Phase 25: earnings-date pips. Filter to dates inside the visible    // candle window — lightweight-charts ignores markers whose time    // does not match a candle, but trimming first keeps the payload small    // and the markers sorted ascending (the API requires it).    const earnings = d.earnings || [];    const candleTimes = new Set(d.candles.map((c) => c.time));    const markers = earnings      .filter((e) => candleTimes.has(e.time))      .map((e) => ({        time: e.time,        position: "aboveBar",        color: EARNINGS_INK,        shape: "circle",      }))      .sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0));    earningsMarkers.setMarkers(markers);  }  // ── indicator toggles ──────────────────────────────────────────────────
modified frontend/static_src/symbol/styles/symbol.scss
@@ -1472,3 +1472,98 @@ a.anomaly__link:hover .anomaly__body {    min-height: 34px;  }}/* ---------- earnings (Phase 25) ---------- */.earn {  padding: 14px 16px;}/* The two headline cells — most recent + next expected — stack on phone and   sit side by side from ~520px, mirroring .div-pace. */.earn__pair {  display: grid;  grid-template-columns: 1fr;  gap: 12px 18px;  margin: 0 0 12px;}@media (min-width: 520px) {  .earn__pair {    grid-template-columns: 1fr 1fr;  }}.earn__cell {  display: flex;  flex-direction: column;  gap: 3px;  padding: 10px 12px;  border: 1px solid var(--rule);  border-radius: 4px;}.earn__cap {  @include eyebrow;  font-size: 0.6rem;}.earn__val {  font-size: 1.05rem;  font-weight: 600;  display: flex;  align-items: baseline;  flex-wrap: wrap;  gap: 2px 8px;}.earn__sub {  @include eyebrow;  font-size: 0.62rem;  font-weight: 400;  color: var(--ink-faint);}.earn__past-title {  @include eyebrow;  color: var(--ink);  margin-bottom: 2px;  padding-top: 14px;  border-top: 1px solid var(--rule-strong);}.earn__list {  margin: 0;  padding: 0;  list-style: none;}.earn-row {  display: flex;  justify-content: space-between;  align-items: baseline;  gap: 16px;  padding: 9px 0;  border-top: 1px solid var(--rule);}.earn-row:first-child {  border-top: none;}.earn-row__date {  font-size: 0.85rem;  color: var(--ink-dim);}.earn-row__ago {  font-size: 0.78rem;  color: var(--ink-faint);}.earn__src {  margin: 12px 0 0;  padding-top: 11px;  border-top: 1px solid var(--rule);  font-size: 0.74rem;  color: var(--ink-faint);}
added migrations/0011_earnings_calendar.sql
@@ -0,0 +1,23 @@-- finance migration 0011: earnings calendar (Phase 25).---- The next-expected earnings date for each stock, fetched from Yahoo's-- `quoteSummary.calendarEvents` module. Past earnings dates are not stored-- here: they ride for free on the 8-K item-2.02 filings the existing-- `filings` table already carries (Phase 14 added the `items` column), so a-- single SELECT against `filings.items LIKE '%2.02%'` lists them.---- Stocks only — ETFs, indexes and futures have no earnings, so these stay-- NULL forever on every non-stock row. A stock Yahoo has no calendar data-- for (Yahoo's coverage is uneven on small caps) keeps `next_earnings_at`-- NULL even after a successful sync, and the symbol page falls back to a-- cadence estimate derived from the past 8-K item-2.02 dates.-- Forward-looking next earnings date (UTC epoch-ms; date precision but-- stored as a timestamp so it sorts and ages alongside the other-- `*_at` columns). NULL when Yahoo has no upcoming date or the stock-- has not been swept yet.ALTER TABLE symbols ADD COLUMN next_earnings_at INTEGER;-- When the Yahoo `earnings_calendar` scheduler section last refreshed-- this stock's `next_earnings_at`. NULL = never swept. Stocks only.ALTER TABLE symbols ADD COLUMN earnings_synced_at INTEGER;
modified src/compute.rs
@@ -1597,6 +1597,84 @@ pub fn drawdown_anomalies(closes: &[f64], dates: &[&str]) -> Vec<AnomalyEvent> {    out}// ── Phase 25: earnings dates ──────────────────────────────────────────────/// Estimate the next earnings date from a stock's recent earnings cadence./// Used as the fallback when Yahoo's `calendarEvents` has no upcoming date/// for the stock (its coverage is uneven on small caps), built from the/// last up-to-four 8-K item-2.02 dates the existing `filings` table already/// carries (Phase 14).////// `dates` is newest-first (matching how the symbol route's `ORDER BY/// filed_at DESC` SELECT returns them). Returns `None` when fewer than two/// priors exist or the spacing reads degenerate (a same-day correction)./// Less reliable than Yahoo when a company moves its reporting calendar,/// but better than no date when Yahoo is empty.pub fn next_earnings_estimate(dates: &[&str]) -> Option<String> {    if dates.len() < 2 {        return None;    }    // Parse the newest-first slice into `NaiveDate`s; drop any unparsable    // entries (defensive — these come from SEC, but the column is TEXT).    let parsed: Vec<chrono::NaiveDate> = dates        .iter()        .filter_map(|s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())        .take(4)        .collect();    if parsed.len() < 2 {        return None;    }    // Gaps between consecutive earnings prints, oldest-to-newest order.    let mut gaps: Vec<i64> = parsed        .windows(2)        .map(|w| (w[0] - w[1]).num_days())        .collect();    if gaps.iter().all(|g| *g <= 1) {        return None;    }    gaps.sort();    let median = gaps[gaps.len() / 2];    // Clamp the median into a sane quarterly band so a stale dataset with    // a few-day gap (multiple 8-Ks tagged 2.02 in one cycle) does not    // project a date in the next week. Most US large-caps file ~91 days    // apart; semi-annual filers ~182.    let median = median.clamp(60, 200);    let next = parsed[0] + chrono::Duration::days(median);    Some(next.format("%Y-%m-%d").to_string())}#[cfg(test)]mod phase25_tests {    use super::*;    #[test]    fn estimates_a_quarterly_cadence() {        // Four prints roughly 91 days apart, newest-first.        let dates = &["2026-05-01", "2026-02-01", "2025-10-30", "2025-08-01"];        let next = next_earnings_estimate(dates).unwrap();        // Median gap ≈ 90 days, so next ≈ 2026-07-30 (give or take a day        // depending on the exact gaps).        let parsed = chrono::NaiveDate::parse_from_str(&next, "%Y-%m-%d").unwrap();        let baseline = chrono::NaiveDate::parse_from_str("2026-05-01", "%Y-%m-%d").unwrap();        let gap = (parsed - baseline).num_days();        assert!((85..=95).contains(&gap), "next gap was {gap}d");    }    #[test]    fn returns_none_on_too_few_priors() {        assert!(next_earnings_estimate(&[]).is_none());        assert!(next_earnings_estimate(&["2026-05-01"]).is_none());    }    #[test]    fn handles_same_day_corrections() {        // Two filings on adjacent days (a press-release and a follow-up): the        // 1-day gap is degenerate, so the estimate is rejected.        let dates = &["2026-05-02", "2026-05-01"];        assert!(next_earnings_estimate(dates).is_none());    }}#[cfg(test)]mod phase28_tests {    use super::*;
modified src/models.rs
@@ -42,6 +42,14 @@ pub struct SymbolRow {    /// from `universe/starter.csv` (Phase 28). The symbol page's chart    /// shows the relative-performance overlay only when this is set.    pub benchmark: Option<String>,    /// Next-expected earnings date from Yahoo's `quoteSummary.calendarEvents`    /// (Phase 25), epoch-ms. NULL when Yahoo has no upcoming date for this    /// stock or the symbol has not been swept yet; the symbol page then    /// falls back to a cadence estimate from past 8-K item-2.02 filings.    pub next_earnings_at: Option<i64>,    /// When this stock's Yahoo earnings-calendar snapshot was last    /// refreshed (Phase 25). NULL = never swept. Stocks only.    pub earnings_synced_at: Option<i64>,    pub last_price: Option<f64>,    pub prev_close: Option<f64>,    pub last_quote_at: Option<i64>,
modified src/providers/yahoo.rs
@@ -342,6 +342,76 @@ impl YahooProvider {        Ok(Some(parse_fund_metadata(result)))    }    /// Fetch the next-expected earnings date for `ticker` from Yahoo's    /// `quoteSummary.calendarEvents` module (Phase 25). One request to the    /// same v10 endpoint that already serves `fund_metadata`, asking only for    /// the calendar module — Yahoo's smallest reply on this endpoint.    ///    /// Returns `Ok(Some(epoch_ms))` when Yahoo has an upcoming earnings date,    /// `Ok(None)` when it knows the symbol but carries no date (Yahoo's    /// coverage is uneven on small caps, and a closely-watched name with a    /// just-passed print also briefly reads empty), or `Ok(None)` for an    /// unknown symbol (404 or `quoteSummary.error`). Gating responses (429 /    /// 503 / 401 / 403) surface as the typed [`RateLimited`], same defensive    /// set as `fund_metadata`.    pub async fn earnings_calendar(&self, ticker: &str) -> Result<Option<i64>> {        let sym = urlencoding::encode(&yahoo_symbol(ticker)).into_owned();        let url = format!(            "https://query1.finance.yahoo.com/v10/finance/quoteSummary/{sym}\             ?modules=calendarEvents"        );        let resp = self.client.get(&url).send().await?;        let status = resp.status();        if matches!(            status,            StatusCode::TOO_MANY_REQUESTS                | StatusCode::SERVICE_UNAVAILABLE                | StatusCode::UNAUTHORIZED                | StatusCode::FORBIDDEN        ) {            let retry_after_secs = resp                .headers()                .get(RETRY_AFTER)                .and_then(|v| v.to_str().ok())                .and_then(|s| s.trim().parse::<i64>().ok());            return Err(anyhow::Error::new(RateLimited {                status: status.as_u16(),                retry_after_secs,            }));        }        if status == StatusCode::NOT_FOUND {            return Ok(None);        }        let resp = resp.error_for_status()?;        let env: QuoteSummaryEnvelope = resp.json().await?;        if env.quote_summary.error.is_some() {            return Ok(None);        }        let Some(result) = env            .quote_summary            .result            .and_then(|mut r| if r.is_empty() { None } else { Some(r.remove(0)) })        else {            return Ok(None);        };        // `earningsDate` is an array; Yahoo populates 1 or 2 entries — the        // confirmed date, or a confirmed/estimated pair. The earliest one        // is the upcoming print. Future events only: a date in the past        // means Yahoo has not yet rolled it forward, so we ignore it.        let now_secs = chrono::Utc::now().timestamp();        let next_secs = result            .calendar_events            .and_then(|c| c.earnings)            .and_then(|e| {                e.earnings_date                    .into_iter()                    .filter_map(|d| Some(d.0 as i64))                    .filter(|s| *s >= now_secs)                    .min()            });        Ok(next_secs.map(|s| s * 1000))    }    /// Identify a symbol: validate it exists on Yahoo and return its name,    /// kind, exchange and currency, alongside the quote the same request    /// carried. Used by the Phase 9 add-symbol flow.
@@ -393,6 +463,23 @@ struct QuoteSummaryResult {    summary_detail: Option<SummaryDetailModule>,    price: Option<PriceModule>,    asset_profile: Option<AssetProfileModule>,    /// `calendarEvents` (Phase 25) — the upcoming earnings date and ex-div    /// date a v10 request can carry alongside the fund modules.    calendar_events: Option<CalendarEventsModule>,}#[derive(Default, Deserialize)]#[serde(rename_all = "camelCase", default)]struct CalendarEventsModule {    earnings: Option<CalendarEarnings>,}#[derive(Default, Deserialize)]#[serde(rename_all = "camelCase", default)]struct CalendarEarnings {    /// Yahoo emits 1 or 2 `RawF64` entries (Unix seconds), the confirmed    /// upcoming date or a confirmed/estimated pair.    earnings_date: Vec<RawF64>,}#[derive(Default, Deserialize)]
modified src/routes/health.rs
@@ -249,6 +249,12 @@ fn job_meta(job: &str) -> (&str, &str) {            "Yahoo quoteSummary snapshot for each ETF — expense ratio, yield, \             NAV, inception, category, fund family, strategy. Refreshed monthly.",        ),        "earnings_calendar" => (            "Earnings calendar",            "Yahoo quoteSummary `calendarEvents` for each stock — the next \             expected earnings date. Refreshed monthly and whenever the \             stored date passes.",        ),        other => (other, ""),    }}
@@ -260,9 +266,10 @@ fn job_rank(job: &str) -> u8 {        "history" => 1,        "sec" => 2,        "fund_metadata" => 3,        "dividends" => 4,        "intraday" => 5,        "daily_close" => 6,        "earnings_calendar" => 4,        "dividends" => 5,        "intraday" => 6,        "daily_close" => 7,        _ => 9,    }}
modified src/routes/symbols.rs
@@ -930,6 +930,130 @@ async fn build_leadership(pool: &sqlx::SqlitePool, ticker: &str, synced: bool) -    }}// ── earnings dates (Phase 25) ─────────────────────────────────────────────/// One past earnings date shaped for the page.#[derive(Serialize)]struct PastEarningsRow {    /// `YYYY-MM-DD`; the template's `shortdate` filter formats it.    date: String,    /// Days from today; positive for past dates.    days_ago: i64,}/// Everything the symbol-page Earnings section needs. Stocks only — every/// caller gates the build on `kind == "stock"`.#[derive(Serialize)]struct EarningsView {    /// Most recent past earnings date (`YYYY-MM-DD`), with a days-ago figure.    most_recent: Option<PastEarningsRow>,    /// Next-expected earnings date (`YYYY-MM-DD`) and days-from-today.    next_date: Option<String>,    next_days: Option<i64>,    /// Where the next date came from: `yahoo` (authoritative), `estimate`    /// (cadence projection), or `unknown` (Yahoo has no date and we cannot    /// estimate one — too few priors).    next_source: &'static str,    /// The last few past earnings dates, newest first. Capped to 4 (one    /// trailing year of a quarterly cadence) per the design pass.    past: Vec<PastEarningsRow>,    /// All past earnings dates surfaced to the chart as ink pips above    /// each matching candle. Kept here so the route's history API can    /// echo them into the chart payload.    chart_dates: Vec<String>,    /// When this stock's earnings-calendar sync last ran, for the section    /// caption. NULL when Yahoo has never been hit for this stock; the page    /// then shows the past dates and the cadence estimate without the "as of"    /// line so it does not lie about a sync that did not happen.    earnings_synced_at: Option<i64>,}/// How many past earnings dates to list on the page. Four covers one trailing/// year of a quarterly cadence; the chart pips show all of them up to the/// chart's visible range.const EARNINGS_PAST_LIMIT: usize = 4;/// Load past earnings dates from `filings.items LIKE '%2.02%'` (Phase 14/// stored 8-K item codes). Newest first; capped to a generous window so a/// company that moved its reporting day still produces a clean median.async fn load_past_earnings(pool: &sqlx::SqlitePool, ticker: &str) -> Vec<String> {    sqlx::query_scalar(        "SELECT filed_at FROM filings \         WHERE ticker = ? AND form LIKE '8-K%' AND items LIKE '%2.02%' \         ORDER BY filed_at DESC, accession DESC LIMIT 16",    )    .bind(ticker)    .fetch_all(pool)    .await    .unwrap_or_default()}/// Build the Earnings section for a stock. Returns `None` when SEC has not/// synced yet (no past dates to anchor the section) and Yahoo also carries/// no next date — the section is hidden cleanly in that case.async fn build_earnings(    pool: &sqlx::SqlitePool,    ticker: &str,    next_earnings_at: Option<i64>,    earnings_synced_at: Option<i64>,) -> Option<EarningsView> {    let past_dates = load_past_earnings(pool, ticker).await;    if past_dates.is_empty() && next_earnings_at.is_none() {        return None;    }    let today = chrono::Utc::now().date_naive();    let days_between = |d: &str| -> Option<i64> {        chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")            .ok()            .map(|nd| (nd - today).num_days())    };    let most_recent = past_dates.first().and_then(|d| {        days_between(d).map(|gap| PastEarningsRow {            date: d.clone(),            days_ago: -gap, // gap is negative for past dates; flip to days-ago.        })    });    // Resolve the next date: Yahoo primary, cadence-estimate fallback.    let (next_date, next_source) = match next_earnings_at {        Some(ts) => {            let date = chrono::DateTime::from_timestamp_millis(ts)                .map(|dt| dt.naive_utc().date().format("%Y-%m-%d").to_string());            (date, "yahoo")        }        None => {            let date_refs: Vec<&str> = past_dates.iter().map(String::as_str).collect();            match compute::next_earnings_estimate(&date_refs) {                Some(d) => (Some(d), "estimate"),                None => (None, "unknown"),            }        }    };    let next_days = next_date.as_deref().and_then(days_between);    let past: Vec<PastEarningsRow> = past_dates        .iter()        .take(EARNINGS_PAST_LIMIT)        .filter_map(|d| {            days_between(d).map(|gap| PastEarningsRow {                date: d.clone(),                days_ago: -gap,            })        })        .collect();    Some(EarningsView {        most_recent,        next_date,        next_days,        next_source,        past,        chart_dates: past_dates,        earnings_synced_at,    })}// ── per-ticker anomaly feed (Phase 16) ────────────────────────────────────/// One row in the anomaly feed, as shaped for the template. Wraps
@@ -1298,6 +1422,23 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)    // the template hides the section.    let anomalies = build_anomalies(&state.pool, &ticker, &symbol.kind, &bars, &facts).await;    // Earnings dates (Phase 25). Stocks only; the past dates ride for free    // off the existing 8-K item-2.02 filings (Phase 14 stored the `items`    // column), the next date is either Yahoo's `calendarEvents` or a cadence    // estimate from those past dates. The chart pips also read off the past    // dates carried in `earnings.chart_dates`.    let earnings = if is_stock {        build_earnings(            &state.pool,            &ticker,            symbol.next_earnings_at,            symbol.earnings_synced_at,        )        .await    } else {        None    };    let extra = minijinja::context! {        title => ticker,        symbol => symbol,
@@ -1312,6 +1453,7 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        leadership => leadership,        dividends => dividends,        anomalies => anomalies,        earnings => earnings,        filings => filings,    };    render(&state, "pages/symbol.html", &format!("/s/{ticker}"), extra)
@@ -1342,6 +1484,14 @@ struct LinePoint {    value: f64,}/// One earnings-date marker for the chart. `YYYY-MM-DD` (matches the/// candle `time` field), so the client can index it against the candle/// series directly.#[derive(Serialize)]struct EarningsMarker {    time: String,}/// The symbol chart payload (Phase 8 + Phase 28): the candles for the/// selected range plus the indicator overlays, each already trimmed to the/// visible window. Phase 28 adds an optional benchmark series — the
@@ -1365,6 +1515,11 @@ struct HistoryResponse {    /// Absent when no benchmark is configured.    #[serde(skip_serializing_if = "Option::is_none")]    benchmark_ticker: Option<String>,    /// Past earnings-date markers for the chart (Phase 25). Each is a    /// `YYYY-MM-DD` matching one of the visible candles; the client draws    /// a small ink dot above each matching bar. Stocks only; empty otherwise.    #[serde(skip_serializing_if = "Vec::is_empty")]    earnings: Vec<EarningsMarker>,}/// Earliest `YYYY-MM-DD` to *show* for a range button. `None` means no limit.
@@ -1477,6 +1632,25 @@ async fn history_api(        _ => Vec::new(),    };    // Earnings-date pips (Phase 25). Stocks only; each pip is dated to an    // 8-K item-2.02 filing date (Phase 14 already stored those in    // `filings.items`). The chart maps each `time` to its matching candle.    let kind: Option<String> = sqlx::query_scalar("SELECT kind FROM symbols WHERE ticker = ?")        .bind(&ticker)        .fetch_optional(&state.pool)        .await        .ok()        .flatten();    let earnings = if kind.as_deref() == Some("stock") {        let dates = load_past_earnings(&state.pool, &ticker).await;        dates            .into_iter()            .map(|time| EarningsMarker { time })            .collect()    } else {        Vec::new()    };    let resp = HistoryResponse {        sma50: line(compute::sma(&closes, 50)),        sma200: line(compute::sma(&closes, 200)),
@@ -1485,6 +1659,7 @@ async fn history_api(        candles: candles.into_iter().skip(start).collect(),        benchmark,        benchmark_ticker,        earnings,    };    Json(resp).into_response()
modified src/scheduler.rs
@@ -106,6 +106,16 @@ const DIVIDENDS_STALE_SECS: i64 = 7 * 24 * 3600;const FUND_METADATA_INTERVAL_SECS: i64 = 24 * 3600;const FUND_METADATA_STALE_SECS: i64 = 30 * 24 * 3600;/// Earnings calendar refresh (Phase 25). Yahoo's `calendarEvents.earnings`/// rolls forward one quarter at a time as each print lands, and a stock's/// next date is irrelevant before it shifts — so a monthly cadence is/// enough to land each new date within a few weeks of when Yahoo learns it./// Daily due-check; one request per stock through the shared `yahoo`/// `EndpointGuard`. The whole sweep also re-runs once the stored/// `next_earnings_at` passes, so a missed roll-forward never sits stale.const EARNINGS_INTERVAL_SECS: i64 = 24 * 3600;const EARNINGS_STALE_SECS: i64 = 30 * 24 * 3600;/// Spawn the scheduler. The returned handle is normally dropped: dropping it/// detaches the task, which then runs for the lifetime of the process.pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle<()> {
@@ -161,6 +171,14 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle            tracing::warn!("[scheduler] bring fund_metadata job forward: {e}");        }        // Earnings calendar job (Phase 25): same bring-forward pattern, so a        // deploy adding the columns backfills the stock universe within a        // tick rather than the daily interval. Resumable; no-stale fast path        // is free.        if let Err(e) = schedule_next(&pool, "earnings_calendar", now_ms()).await {            tracing::warn!("[scheduler] bring earnings_calendar job forward: {e}");        }        // Prune's last-run time is loop-local: a restart simply re-prunes once,        // which is harmless (local-only DELETEs, no network).        let mut last_prune: Option<i64> = None;
@@ -237,6 +255,19 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle                Err(e) => tracing::warn!("[scheduler] fund_metadata due-check: {e}"),            }            // Earnings calendar (Phase 25): sweep stocks whose next-expected            // earnings date has gone stale or already passed. Same Yahoo            // guard; one request per stock per month in steady state.            match is_due(&pool, "earnings_calendar", now_ms()).await {                Ok(true) => {                    if let Err(e) = run_earnings_calendar(&pool, &config, &hub).await {                        tracing::warn!("[scheduler] earnings_calendar: {e:#}");                    }                }                Ok(false) => {}                Err(e) => tracing::warn!("[scheduler] earnings_calendar due-check: {e}"),            }            // Intraday quotes: demand-driven (only symbols a browser is            // viewing). Inside a trading session every viewed symbol is            // polled; outside it, only viewed futures, which trade nearly
@@ -1375,6 +1406,149 @@ async fn mark_fund_metadata_synced(pool: &SqlitePool, ticker: &str) -> sqlx::Res    Ok(())}// ────────────── stock earnings calendar sweep (Phase 25) ──────────────────/// Sweep stocks whose next-expected earnings date has gone stale (monthly)/// or already passed. One request per stock through the shared `yahoo`/// `EndpointGuard`; mirrors `run_fund_metadata`'s shape — guard-paced,/// resumable, broadcast-to-/health.async fn run_earnings_calendar(    pool: &SqlitePool,    config: &Config,    hub: &Hub,) -> anyhow::Result<()> {    let started = now_ms();    let next = started + EARNINGS_INTERVAL_SECS * 1000;    let cutoff = started - EARNINGS_STALE_SECS * 1000;    // Refresh a stock when either its sync-timestamp has aged out OR its    // stored next date has already passed (the print landed; Yahoo should    // carry the following quarter's date by now).    let stale: Vec<String> = sqlx::query_scalar(        "SELECT ticker FROM symbols \         WHERE kind = 'stock' \           AND ( \                earnings_synced_at IS NULL OR earnings_synced_at < ? \                OR (next_earnings_at IS NOT NULL AND next_earnings_at < ?) \           ) \         ORDER BY ticker",    )    .bind(cutoff)    .bind(started)    .fetch_all(pool)    .await?;    if stale.is_empty() {        mark_ok(pool, "earnings_calendar", Some(next)).await?;        return Ok(());    }    mark_fetching(pool, "earnings_calendar").await?;    notify_health(hub);    tracing::info!(        "[scheduler] earnings_calendar: refreshing {} stocks",        stale.len()    );    let yahoo = YahooProvider::new(providers::http::build_client(config));    let guard = EndpointGuard::with_budget(pool.clone(), yahoo.name(), YAHOO_BUDGET);    let t0 = Instant::now();    let mut ok = 0i64;    let mut empty = 0i64;    let mut errors = 0i64;    let mut stopped: Option<String> = None;    for ticker in &stale {        match guard.acquire().await? {            Permit::Granted => {}            Permit::Denied(why) => {                stopped = Some(why);                break;            }        }        match yahoo.earnings_calendar(ticker).await {            Ok(Some(ts_ms)) => {                guard.record_success().await?;                if let Err(e) = store_earnings_next(pool, ticker, Some(ts_ms)).await {                    tracing::warn!("[scheduler] earnings_calendar store {ticker}: {e:#}");                    errors += 1;                    continue;                }                ok += 1;            }            // Yahoo answered cleanly but has no upcoming date for this            // stock (uneven coverage). Clear the stored next date so the            // symbol page falls back to a cadence estimate, and stamp the            // sync so the next sweep does not re-fetch the same empty.            Ok(None) => {                guard.record_success().await?;                if let Err(e) = store_earnings_next(pool, ticker, None).await {                    tracing::warn!("[scheduler] earnings_calendar store {ticker}: {e:#}");                    errors += 1;                    continue;                }                empty += 1;            }            Err(e) => {                guard.record_failure(&e).await?;                errors += 1;                tracing::warn!("[scheduler] earnings_calendar {ticker}: {e:#}");            }        }    }    let dur = t0.elapsed().as_millis() as i64;    let detail = format!(        "{ok}/{} stocks ({empty} empty, {errors} errors)",        stale.len()    );    match stopped {        Some(why) => {            let full = format!("stopped early ({why}); {detail}");            tracing::warn!("[scheduler] earnings_calendar: {full}");            log_fetch(                pool, "earnings_calendar", "yahoo", "skipped",                Some(&full), Some(ok), dur, started,            )            .await?;        }        None => {            tracing::info!("[scheduler] earnings_calendar: {detail}");            log_fetch(                pool, "earnings_calendar", "yahoo", "ok",                Some(&detail), Some(ok), dur, started,            )            .await?;        }    }    mark_ok(pool, "earnings_calendar", Some(next)).await?;    notify_health(hub);    Ok(())}/// Write one stock's next-earnings date and stamp it as freshly synced./// `next` is `None` when Yahoo has no upcoming date (the stored value is/// cleared so the page falls back to a cadence estimate). `pub(crate)`:/// the add-symbol backfill reuses it.pub(crate) async fn store_earnings_next(    pool: &SqlitePool,    ticker: &str,    next: Option<i64>,) -> sqlx::Result<()> {    let now = now_ms();    sqlx::query(        "UPDATE symbols SET next_earnings_at = ?, earnings_synced_at = ?, \                            updated_at = ? \         WHERE ticker = ?",    )    .bind(next)    .bind(now)    .bind(now)    .bind(ticker)    .execute(pool)    .await?;    Ok(())}/// Fill in `symbols.cik` for any stock found in the bulk SEC ticker map./// Returns how many were newly resolved.async fn resolve_ciks(pool: &SqlitePool, map: &HashMap<String, String>) -> sqlx::Result<i64> {
@@ -1776,6 +1950,13 @@ pub(crate) async fn backfill_symbol(pool: &SqlitePool, config: &Config, ticker:    if kind == "etf" {        backfill_fund_metadata(pool, config, ticker).await;    }    // Phase 25: stocks get their Yahoo earnings calendar pulled too — so a    // user-added stock's symbol page carries the next-expected earnings    // date the moment the add returns, rather than waiting on the next    // scheduler cycle.    if kind == "stock" {        backfill_earnings_calendar(pool, config, ticker).await;    }    // SEC data covers stocks and ETFs; indexes and futures do not file. The    // whole SEC step is skipped with no contact email configured, as `run_sec`
@@ -1816,6 +1997,27 @@ async fn backfill_fund_metadata(pool: &SqlitePool, config: &Config, ticker: &str    }}/// Pull and store a freshly-added stock's next-expected earnings date/// (Phase 25). Mirrors `backfill_dividends`: same `yahoo` guard,/// best-effort, no failure propagated to the add-symbol response.async fn backfill_earnings_calendar(pool: &SqlitePool, config: &Config, ticker: &str) {    let yahoo = YahooProvider::new(providers::http::build_client(config));    let guard = EndpointGuard::with_budget(pool.clone(), yahoo.name(), YAHOO_BUDGET);    match guarded(&guard, yahoo.earnings_calendar(ticker)).await {        Some(Ok(next)) => match store_earnings_next(pool, ticker, next).await {            Ok(()) => {                tracing::info!(                    "[backfill] {ticker} <- earnings_calendar ({})",                    next.map(|_| "next set").unwrap_or("no upcoming date"),                );            }            Err(e) => tracing::warn!("[backfill] store earnings {ticker}: {e:#}"),        },        Some(Err(e)) => tracing::warn!("[backfill] earnings_calendar {ticker}: {e:#}"),        None => {}    }}/// Pull and store a freshly-added symbol's dividend / distribution history/// (Phase 26 + Phase 28: stocks and ETFs both use this path). Routed through/// the same `yahoo` guard the dividends sweep uses. Best-effort: a guard
modified templates/pages/symbol.html
@@ -204,6 +204,58 @@  {# --- fundamentals + financials: stocks only --- #}  {% if symbol.kind == 'stock' %}  {# --- earnings dates (Phase 25): next-expected date with provenance, days         since the last print, and a short list of recent past dates. Past         dates come from 8-K item-2.02 filings (already stored by Phase 14);         the next date is Yahoo's `calendarEvents` when present, otherwise a         cadence estimate from those past dates. --- #}  {% if earnings %}  <h2 class="section-title">Earnings{% if earnings.earnings_synced_at %}<span class="section-title__asof">calendar synced from Yahoo {{ earnings.earnings_synced_at|ago }}</span>{% endif %}</h2>  <section class="panel earn">    <dl class="earn__pair">      <div class="earn__cell">        <dt class="earn__cap">Most recent</dt>        {% if earnings.most_recent %}        <dd class="earn__val num">{{ earnings.most_recent.date|shortdate }}<span class="earn__sub">{{ earnings.most_recent.days_ago }} day{% if earnings.most_recent.days_ago != 1 %}s{% endif %} ago</span></dd>        {% else %}        <dd class="earn__val">&mdash;<span class="earn__sub">no past earnings filed</span></dd>        {% endif %}      </div>      <div class="earn__cell">        <dt class="earn__cap">Next expected</dt>        {% if earnings.next_date %}        <dd class="earn__val num">{{ earnings.next_date|shortdate }}<span class="earn__sub">          {%- if earnings.next_days is not none -%}            {%- if earnings.next_days >= 0 -%}              in {{ earnings.next_days }} day{% if earnings.next_days != 1 %}s{% endif %}            {%- else -%}              {{ -earnings.next_days }} day{% if -earnings.next_days != 1 %}s{% endif %} ago            {%- endif -%}          {%- endif -%}          {% if earnings.next_source == 'estimate' %} &middot; estimated from cadence{% endif %}        </span></dd>        {% else %}        <dd class="earn__val">&mdash;<span class="earn__sub">no upcoming date on file</span></dd>        {% endif %}      </div>    </dl>    {% if earnings.past %}    <div class="earn__past">      <h3 class="earn__past-title">Recent earnings dates</h3>      <ul class="earn__list">        {% for e in earnings.past %}        <li class="earn-row">          <span class="earn-row__date num">{{ e.date|shortdate }}</span>          <span class="earn-row__ago">{{ e.days_ago }} day{% if e.days_ago != 1 %}s{% endif %} ago</span>        </li>        {% endfor %}      </ul>    </div>    {% endif %}    <p class="earn__src">Past earnings dates come from {{ symbol.ticker }}&rsquo;s 8-K item&nbsp;2.02 filings; the next date is Yahoo&rsquo;s when available, otherwise estimated from the recent reporting cadence.</p>  </section>  {% endif %}  {# --- stock health read (Phase 17): a single non-advice synthesis of the         three signals below (fundamentals + price/growth trajectory +         leadership stability), so the headline reads in one glance before