repos

Phase 26: dividend payouts on the symbol page

7608b06c by Isaac Bythewood · 1 month ago

Phase 26: dividend payouts on the symbol page

A new Dividends section on /s/{ticker}, stocks only, between Fundamentals
and Leadership: inferred cadence, prior-year and YTD totals, a
count-tempered on-track projection, and a per-event payout history list.

- migrations/0007_dividends.sql: dividends(ticker, ex_date, amount) table
  + symbols.dividends_synced_at.
- YahooProvider::dividends fetches range=5y, interval=1d, events=div from
  the existing v8/finance/chart endpoint; the same RateLimited / 404
  semantics as quote/lookup so the yahoo guard sees the same signals.
- scheduler::run_dividends sweeps stale stocks weekly through the existing
  yahoo EndpointGuard (no new guard row); brought forward to the first
  tick on boot like the SEC job. store_dividends replaces the per-stock
  history wholesale on each successful fetch.
- scheduler::backfill_symbol now also pulls a freshly-added stock's
  dividend history before responding, per Phase 21's intent that a new
  symbol's page is complete the moment the add returns.
- compute::infer_cadence reads the median gap between the last up-to-8
  payouts to classify monthly / quarterly / semi-annual / annual /
  irregular. compute::dividend_pace builds prior-year + YTD totals, the
  cadence caption, a projection of YTD * expected_n / declared_so_far
  (count-tempered rather than elapsed-fraction-of-year), and a
  Good/Ok/Bad grade on a +/-2% flat band (rise-is-good).
- symbol.html renders the new section; symbol.scss styles a pace card
  row, the on-track pill, and a per-event history list. A swept stock
  with no payouts hides the section entirely (no heading over an empty
  table); an unswept stock shows the pending note in place.
- health.rs lists the new 'dividends' job between 'sec' and 'intraday'.

Per-share figures show to the cent normally and widen to 4dp for
sub-cent payouts (monthly REITs).

PLAN.md: Phase 26 marked done; Phase 27 (backup providers for
redundancy) captured as a new backlog phase from a vibe-coding side note
during the build.
modified PLAN.md
@@ -32,8 +32,8 @@ and resume cleanly from this file alone, keeping token use low._Last updated: 2026-05-22_**Current phase: none in progress. Phase 22 (show data age everywhere) iscomplete, verified, and deployed.** Phases 0**Current phase: Phase 26 (dividend payouts) is complete and verifiedlocally; the deploy has not been pushed yet.** Phases 0through 12 (the MVP) plus Phase 14 (company leadership), Phase 18 (ETFprofiles), Phase 20 (strongest & weakest home panels), Phase 21 (home &search refinements), Phase 23 + 24 (financials table) and Phase 22 (data-age
@@ -41,8 +41,12 @@ captions) are complete, verified, and **live in production athttps://finance.bythewood.me**. Phase 22 adds a consistent, quiet data-agecaption across the whole app — the home dashboard, search, and everysymbol-page data section, not just `/health` — see the Done list and thedecisions log. Remaining post-MVP work: the loose-ordered Phase 13, 15, 16,17, 19, 25, 26 backlog.decisions log. Phase 26 (the user picked it as the next backlog phase)adds a stock Dividends section on the symbol page — inferred cadence,prior-year and YTD totals, a count-tempered on-track projection, and a per-event payout history — backed by a new weekly Yahoo `dividends` schedulerjob. Built locally; not yet deployed (commit pending). Remainingpost-MVP work: the loose-ordered Phase 13, 15, 16, 17, 19, 25, 27 backlog.**Roadmap (restructured 2026-05-22, see decisions log):** the home-pageredesign and commodities are pre-ship MVP phases. Order: 9 Search +
@@ -755,18 +759,79 @@ schema, unused for now.    (360px) render with no horizontal overflow and zero console errors; a    too-long caption wraps cleanly to a second line at 360px.- **Phase 26 dividend payouts.** Complete and verified locally; not yet  deployed. Per-payout dividend history sourced from Yahoo's chart endpoint  via `events=div`, surfaced as a new Dividends section on the symbol page  between Fundamentals and Leadership. Stocks only — ETFs, indexes and  futures show none of it.  - **Migration `0007`** adds the `dividends` table keyed `(ticker, ex_date)`    with a per-share `amount`, plus `symbols.dividends_synced_at`. Five-year    history pulled per stock (the user's pick over `max` or `5y` shorter    windows: enough for prior-year + YTD pace and a long visible list, while    keeping the per-call payload modest).  - **Yahoo provider.** A new `YahooProvider::dividends(ticker)` inherent    method calls the same v8 chart endpoint with `interval=1d&range=5y&events=div`    and parses the `events.dividends` map. Like `lookup`, a 429/503 surfaces    as the typed `RateLimited` (the `yahoo` `EndpointGuard` trips at once);    a 404 or `chart.error` returns an empty list (a clean "no dividends    history" answer, not a guard failure). Non-positive amounts and unparsable    timestamps are filtered.  - **Scheduler.** A new `dividends` job sweeps every stock whose    `dividends_synced_at` is older than a week, paced through the existing    `yahoo` guard alongside intraday and daily-close. Daily due-check;    skipped wholesale on a no-stale run. Brought forward to the first tick    on boot, like `sec`, so a deploy adding the table backfills the universe    within a tick rather than the daily interval. Resumable: each stock's    `dividends_synced_at` is stamped only on success.  - **Pace math (`compute.rs`).** `infer_cadence` reads the median gap    between the last up-to-8 payouts to classify a stock as monthly /    quarterly / semi-annual / annual / irregular. `dividend_pace` builds a    `DividendPace` carrying prior-year + YTD totals, the inferred cadence's    caption, and a count-tempered projection: `YTD × expected_n /    declared_n_so_far` (the user picked this over an elapsed-fraction-of-year    projection, since it does not misread a quarterly payer just after a    payout). The on-track grade is `Good` / `Ok` / `Bad` on a ±2% flat band,    rise-is-good (matching the Phase 24 trend reading).  - **Symbol page.** A new "Dividends" section between Fundamentals and    Leadership (the user's slot pick). Header: the cadence caption + the    on-track verdict pill. Below: a 3-card pace row — prior-year total, YTD    so far (with a payment count), and the count-tempered projection (with a    coloured `+x.x% vs <prior year>` sub). A provenance note labels the    projection as an estimate between payouts. Below that, a per-event    history list (date + per-share amount, newest first). A stock that has    not been swept yet shows a pending note; a swept stock with no payouts    in the past five years hides the section entirely (it pays no dividend).  - **Add-symbol backfill** (`scheduler::backfill_symbol`) now also pulls a    new stock's dividend history before responding, so a user-added stock's    Dividends section is complete the moment the add returns.  - **Health page** lists the new `dividends` job between `sec` and    `intraday` (job_meta + job_rank). The job runs on the existing `yahoo`    endpoint guard, so no new guard row was needed.  - Currency formatting: per-share figures show as `$0.24` to the cent    normally and widen to `$0.0625` for sub-cent payouts (the monthly REIT    case), so a small payment is not lost to rounding.  - Verified: cargo + bun build clean; the boot sweep ran and stamped every    curated stock; `/s/AAPL` rendered the Dividends section with the    quarterly cadence caption, prior-year + YTD totals, the projection with    its on-track badge, and the per-event history list; a non-paying stock    hid the section; ETFs / indexes / futures showed no section.**Resuming, next action****Phase 22 (show data age everywhere) is complete, verified, and deployed**to production (2026-05-22, commit `39a863e`). The MVP plus Phase 14,Phase 18, Phase 20, Phase 21, Phase 23 + 24 and Phase 22 are all live athttps://finance.bythewood.me. No phase is in progress. Remaining post-MVPwork: the loose-ordered Phase 13, 15, 16, 17, 19, 25, 26 backlog; the userpicks which to take next. A small Phase 9 bug fix (the `/search` "Add"affordance for short tickers like `W`) shipped 2026-05-22 alongside thebacklog capture of Phases 25 and 26; see the decisions log. There is stillno GitHub repo for finance: the user deferred that; if one is createdlater, add it as `origin` and the `overshard/finance` slug already intaproot's `projects.conf` lines up.**Phase 26 (dividend payouts) is complete and verified locally; the deployis the next step.** The MVP plus Phase 14, Phase 18, Phase 20, Phase 21,Phase 23 + 24 and Phase 22 are all live at https://finance.bythewood.me.Phase 26 builds clean and runs end-to-end on the dev box; pushing`server master` ships it (migration `0007` applies on boot, the new`dividends` scheduler job backfills the universe over its first tick).Remaining post-MVP work: the loose-ordered Phase 13, 15, 16, 17, 19, 25, 27backlog; the user picks which to take next. Phase 27 (provider redundancy)was captured 2026-05-22 from a vibe-coding side note while Phase 26 wasmid-build; see the decisions log. A small Phase 9 bug fix (the `/search`"Add" affordance for short tickers like `W`) shipped 2026-05-22 alongsidethe backlog capture of Phases 25 and 26; see the decisions log. There isstill no GitHub repo for finance: the user deferred that; if one iscreated later, add it as `origin` and the `overshard/finance` slug alreadyin taproot's `projects.conf` lines up.Note: Phase 18 added the `quick-xml` crate (N-PORT XML streaming parser) andmigration `0005`. A fresh `make run` applies `0005`; the ETF fund profiles
@@ -1172,7 +1237,17 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data).  (3); piece (2) may want a small `next_earnings_at` column on `symbols`  if it takes the Yahoo calendar path.- [ ] **Phase 26: Dividend payout history and pace.** (Captured 2026-05-22- [x] **Phase 26: Dividend payout history and pace.** Complete and verified  locally 2026-05-22 (not yet deployed); see the Phase 26 Done entry in  Status and the decisions log. Per-payout dividend history from Yahoo  chart `events.dividends`, new `dividends` table (migration `0007`),  a weekly `dividends` scheduler job on the existing `yahoo` guard, and a  symbol-page Dividends section between Fundamentals and Leadership with  inferred cadence, prior-year + YTD totals, a count-tempered on-track  projection, and a per-event history list. Ex-div pips on the candlestick  chart deferred to Phase 25 per the design Q&A. Stocks only.  (Captured 2026-05-22  as a vibe-coding side note alongside Phase 25.) On the symbol page,  surface the dividend cadence and how the current year is tracking against  the last. Pieces:
@@ -1202,6 +1277,38 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data).  each stock's dividend history through the `yahoo` `EndpointGuard` on a  slow cadence (weekly is enough; the data rarely changes).- [ ] **Phase 27: Backup providers for redundancy.** (Captured 2026-05-22  from a vibe-coding side note while Phase 26 was mid-build.) For each  data concern (history / live quotes / fundamentals / dividends),  configure one or more *backup* upstreams behind the existing provider  traits, and switch over to the backup whenever the primary is unhappy:  its `EndpointGuard` breaker has opened, its hourly budget is spent, or  the primary returned a transport error. The user considers this critical  for keeping the app up when an upstream blocks, rate-limits, or simply  has an outage — "as much redundancy as possible".  Open design points to settle when building:  (1) **Trait composition.** A small `MultiProvider<T: Provider>` wrapper  that holds a primary + ordered fallbacks; `acquire()` tries the primary's  guard first, falls through on `Permit::Denied` / typed error, and records  which source succeeded for the health page. Likely lives in  `src/providers/mod.rs`.  (2) **Candidate sources.** Likely free/free-tier: Tiingo, Alpha Vantage,  Polygon (with caveats), or Stooq's CSV mirror for daily history; a second  Yahoo path or Marketstack for quotes; a fixed snapshot from `companyfacts`  for fundamentals where SEC is already the canonical source. Each gets  its own `EndpointGuard` row and budget (per PLAN.md's anti-spam policy).  (3) **Routing rules.** Stickiness — once a fallback is in use, when does  the system retry the primary? Likely a probe at the next due-check tick  once the primary's breaker closes. Per-symbol overrides for upstreams  that cover a different sub-universe (e.g. some sources don't carry  futures or non-US stocks).  (4) **Health page surfacing.** The page should show which source is  currently in use per concern, when a fallback last took over, and the  full list of configured backups with their own breaker / budget state.  No new core feature, but a meaningful change to the provider layer; it  goes beyond a small refinement, so the user can pick the ordering after  the current backlog.---## Key files
@@ -1808,6 +1915,61 @@ finance/  offer still shows only when the query matched nothing, or matched at least  one result as a ticker substring (a name-only search such as `Inc` does not  trigger it). The add panel can now render above a populated results grid.- **2026-05-22 — Phase 26 picked next; design Q&A settled four points.**  Asked which loose-ordered backlog phase to take next, the user chose  Phase 26 (dividend payouts). A short design pass settled the open  questions the plan had flagged. (1) **Chart pips deferred to Phase 25.**  Phase 26 ships the page section only; Phase 25 will build the  lightweight-charts marker layer for earnings + ex-div together, which  keeps Phase 26 tight and avoids writing marker plumbing without an  earnings caller to compare against. (2) **Yahoo dividend-history depth:  range=5y, interval=1d, events=div** — enough for prior-year + YTD pace  and a long visible history list, with a modest per-call payload; the  candle stream itself is discarded by the new method. (3) **Count-tempered  projection.** Project YTD up by the ratio of expected payments this year  to declared so far (so a quarterly payer at end-of-Q1 projects ×4, not  ×~4 by elapsed days). Reads more honest around the cadence than an  elapsed-fraction-of-year approach. (4) **Symbol-page slot.** The new  Dividends section sits between Fundamentals and Leadership, so the page  reads as key stats → fundamentals → financials → dividends → leadership  → filings.- **2026-05-22 — Phase 26 dividend payouts shipped (local).** Yahoo is now  the source for a third concern beyond quotes and intraday bars. Design  calls made during the build: (1) the `dividends(ticker)` method is  inherent to `YahooProvider`, not behind a new trait — the data lives on  the same v8 chart endpoint as the existing quote / lookup methods, and  the Phase 27 backlog (provider redundancy) is the right place to lift it  to a trait if it gains a second source. (2) The new `dividends`  scheduler job rides the existing `yahoo` `EndpointGuard` (no new row,  budget shared with intraday + daily-close); declared dividends drift  slowly, so a weekly staleness window keeps the steady-state cost tiny.  (3) A stock that has not been swept yet shows a "not synced yet" pending  note in place; a swept stock with no payouts in the past five years  hides the section entirely (it pays no dividend — no heading over an  empty table). (4) Cadence is inferred from the *median* gap between the  last up-to-8 payouts, with comfortable bands (≤45d monthly, ≤130d  quarterly, ≤220d semi-annual, ≤450d annual) so a single irregular gap  does not throw the classification. (5) The on-track grade uses a small  ±2% flat band to keep a rounding-grade payment change from reading as  "growing" or "shrinking" (PACE_FLAT_BAND). (6) The add-symbol backfill  (`scheduler::backfill_symbol`) was extended to pull a new stock's  dividend history before responding, mirroring Phase 21's intent that a  user-added symbol's page is complete the moment the add returns.  Per-share figures display to the cent normally (`$0.24`) and widen to  4dp for sub-cent payouts (`$0.0625`, the monthly REIT case) so a small  payment is not lost to rounding. Not yet deployed; ships on the next  `git push server master` (migration `0007` applies on the box).- **2026-05-22 — Phase 27 captured: backup providers for redundancy.**  While Phase 26 was mid-build the user floated a wish for additional  *backup* providers per data concern (history / quotes / fundamentals /  dividends), so the app can switch over when the primary's  `EndpointGuard` is unhappy — breaker open, hourly budget spent, or any  transport error — and so we have "as much redundancy as possible". Per  the vibe-coding rule the idea was budgeted into the plan rather than  acted on mid-phase: see the new Phase 27 entry in the Phases list, which  also enumerates the four open design points to settle when building (a  `MultiProvider<T>` wrapper, candidate sources, routing rules and  stickiness, health-page surfacing of the active source).- **2026-05-22: two side notes captured as Phases 25 and 26.** While waiting  on the search-Add fix to deploy, the user floated two ideas: (1) earnings  dates on the symbol page (last and next, with days-to / days-since), and
modified frontend/static_src/symbol/styles/symbol.scss
@@ -1020,6 +1020,120 @@  }}/* ---------- dividends (Phase 26) ---------- */.div-panel {  padding: 14px 16px;}/* headline: inferred cadence over an on-track pill on the right */.div-head {  display: flex;  align-items: center;  justify-content: space-between;  flex-wrap: wrap;  gap: 8px 14px;  margin-bottom: 14px;}.div-cadence {  font-size: 0.95rem;  font-weight: 600;  color: var(--ink);}/* a 2- or 3-column row of prior-year / YTD / projected totals */.div-pace {  display: grid;  grid-template-columns: 1fr;  gap: 12px 18px;  margin: 0 0 12px;}@media (min-width: 520px) {  .div-pace {    grid-template-columns: repeat(3, 1fr);  }}.div-pace__item {  display: flex;  flex-direction: column;  gap: 3px;  padding: 10px 12px;  border: 1px solid var(--rule);  border-radius: 4px;}.div-pace__cap {  @include eyebrow;  font-size: 0.6rem;}.div-pace__val {  font-size: 1.05rem;  font-weight: 600;  display: flex;  align-items: baseline;  flex-wrap: wrap;  gap: 2px 8px;}.div-pace__sub {  @include eyebrow;  font-size: 0.62rem;  font-weight: 400;}.div-pace__sub--good { color: var(--up); }.div-pace__sub--bad  { color: var(--down); }.div-pace__sub--ok   { color: var(--ink-dim); }.div-pace__src {  margin: 0 0 16px;  padding-top: 11px;  border-top: 1px solid var(--rule);  font-size: 0.74rem;  color: var(--ink-faint);}/* per-event history; mirrors the .roster row layout */.div-hist__title {  @include eyebrow;  color: var(--ink);  margin-bottom: 2px;  padding-top: 14px;  border-top: 1px solid var(--rule-strong);}.div-hist {  margin: 0;  padding: 0;  list-style: none;}.div-row {  display: flex;  justify-content: space-between;  align-items: baseline;  gap: 16px;  padding: 9px 0;  border-top: 1px solid var(--rule);}.div-row:first-child {  border-top: none;}.div-row__date {  font-size: 0.85rem;  color: var(--ink-dim);}.div-row__amt {  font-size: 0.95rem;  font-weight: 600;}/* ---------- desktop layering ---------- */@media (min-width: $bp-md) {  #chart {
added migrations/0007_dividends.sql
@@ -0,0 +1,27 @@-- finance migration 0007: dividend payout history (Phase 26).---- A stock's per-event dividend history (ex-dividend date + per-share amount),-- pulled from Yahoo's chart `events.dividends` series. Used on the symbol page-- to show the payout cadence, prior-year and YTD totals, and an on-track pace-- read. Stocks only: ETFs, indexes and futures do not pay regular dividends-- in this app's sense.-- When this stock's dividend history was last refreshed from Yahoo. NULL =-- never. Driven by the new `dividends` scheduler job (weekly cadence).ALTER TABLE symbols ADD COLUMN dividends_synced_at INTEGER;-- One row per declared dividend payment. Keyed on (ticker, ex_date): a single-- payout is uniquely identified by its ex-dividend date — the first trading-- day on which a new buyer does NOT receive the upcoming payment, which is the-- date Yahoo's chart events series timestamps each event by.---- Amounts are per-share, in the symbol's reporting currency (USD for the-- universe we follow). A correction or backfill of a known payout overwrites-- the prior amount via the primary-key conflict.CREATE TABLE dividends (    ticker  TEXT NOT NULL REFERENCES symbols(ticker) ON DELETE CASCADE,    ex_date TEXT NOT NULL,                  -- ex-dividend date, YYYY-MM-DD    amount  REAL NOT NULL,                  -- per-share, in symbols.currency    PRIMARY KEY (ticker, ex_date));CREATE INDEX dividends_ticker_date ON dividends(ticker, ex_date DESC);
modified src/compute.rs
@@ -547,6 +547,198 @@ fn earnings_growth(net_income: Option<f64>, prev_net_income: Option<f64>) -> Rat    mk(KEY, LABEL, EXPLAIN, format!("{v:+.1}%"), grade, reading)}// ──────────────────────── dividend pace (Phase 26) ─────────────────────────//// Inferred cadence + an on-track read for a stock's dividend payouts. Inputs// are sorted (ex_date, amount) pairs and a reference "today" date. Pure code,// kept here next to the other graded reads; the route formats display./// How frequently a stock pays out — inferred from the median gap between its/// recent ex-dividend dates. Drives both the page's "Pays …" caption and the/// count-tempered projection of the current year's total.#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]#[serde(rename_all = "lowercase")]pub enum Cadence {    /// One payment per year.    Annual,    /// Two per year (~180-day gap), e.g. some European dual-listings.    SemiAnnual,    /// Four per year (~90-day gap), the US norm.    Quarterly,    /// Twelve per year, e.g. monthly-paying real-estate trusts.    Monthly,    /// Cadence does not fit a clean pattern (special one-off, etc.).    Irregular,    /// No payouts to read from.    None,}impl Cadence {    /// A short caption for the page header, e.g. `Pays quarterly`.    pub fn caption(self) -> &'static str {        match self {            Cadence::Annual => "Pays annually",            Cadence::SemiAnnual => "Pays twice a year",            Cadence::Quarterly => "Pays quarterly",            Cadence::Monthly => "Pays monthly",            Cadence::Irregular => "Irregular cadence",            Cadence::None => "No dividends recorded",        }    }    /// Expected number of payouts per calendar year, for the projection.    /// `None` for `Irregular` / `None`, where a clean projection is misleading.    fn expected_per_year(self) -> Option<u32> {        match self {            Cadence::Annual => Some(1),            Cadence::SemiAnnual => Some(2),            Cadence::Quarterly => Some(4),            Cadence::Monthly => Some(12),            Cadence::Irregular | Cadence::None => None,        }    }}/// Infer cadence from the most recent payouts' ex-date gaps. Takes a sorted/// (oldest first) slice of dates as `YYYY-MM-DD` strings; reads the median/// gap across the last few payments so a single irregular one-off does not/// throw the classification. Returns `Irregular` when the median lands outside/// every clean band, and `None` when there is too little to infer from.pub fn infer_cadence(ex_dates_oldest_first: &[String]) -> Cadence {    if ex_dates_oldest_first.is_empty() {        return Cadence::None;    }    if ex_dates_oldest_first.len() == 1 {        // One payout: not enough to infer a cadence, but better to flag it as        // irregular than claim a clean annual.        return Cadence::Irregular;    }    // The most recent up-to-8 payouts give a stable median while still    // reflecting any recent change in cadence.    let tail = &ex_dates_oldest_first[ex_dates_oldest_first.len().saturating_sub(8)..];    let parsed: Vec<chrono::NaiveDate> = tail        .iter()        .filter_map(|d| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())        .collect();    if parsed.len() < 2 {        return Cadence::Irregular;    }    let mut gaps: Vec<i64> = parsed        .windows(2)        .map(|w| (w[1] - w[0]).num_days())        .collect();    gaps.sort();    // Median rather than mean so a single irregular gap does not skew it.    let median = gaps[gaps.len() / 2];    match median {        // Each band leaves comfortable slack: a quarterly payer's gaps range        // ~80-95d in practice depending on the calendar.        d if d <= 45 => Cadence::Monthly,        d if d <= 130 => Cadence::Quarterly,        d if d <= 220 => Cadence::SemiAnnual,        d if d <= 450 => Cadence::Annual,        _ => Cadence::Irregular,    }}/// The on-track read for a stock's dividends: prior-year and YTD totals, the/// projected current-year total, and a graded verdict on whether the company/// is tracking ahead of, on, or behind its prior-year payout.#[derive(Debug, Clone, Serialize)]pub struct DividendPace {    pub cadence: Cadence,    /// Short caption derived from `cadence`, e.g. `Pays quarterly`. Carried    /// so the template renders it without poking at the method.    pub cadence_caption: &'static str,    /// Sum of payouts in the previous calendar year, per share.    pub prior_year_total: f64,    /// Sum of payouts in the current calendar year so far, per share.    pub ytd_total: f64,    /// Number of payouts declared so far this calendar year.    pub ytd_count: u32,    /// Projected current-year total per share, scaling YTD by the count-    /// tempered factor (`expected_n / declared_n_so_far`). `None` when the    /// cadence is unclear, no payouts have landed this year, or there is no    /// prior-year baseline to compare against.    pub projection: Option<f64>,    /// Projection vs prior-year, as a percent change. `None` whenever    /// `projection` is.    pub pct_change: Option<f64>,    /// On-track verdict (rise is good for dividends, matching the Phase 24    /// trend reading): `Good` for a clear rise, `Bad` for a clear fall, `Ok`    /// for a small move or a flat year, `Unknown` whenever `projection` is.    pub grade: Grade,    /// One-word badge text derived from `grade` — `Strong` / `Fair` / `Weak`,    /// or `No data` when there is nothing to read.    pub verdict: &'static str,}/// A small one-week-each-side band around prior-year that reads as flat, so a/// rounding-grade payment increase does not register as "growing" //// "shrinking".const PACE_FLAT_BAND: f64 = 2.0;/// Build a [`DividendPace`] from dividend events oldest first. `today` carries/// the date the YTD window closes at (taken from the route's clock). Returns a/// `DividendPace` even when there is little to say, so the page can show the/// raw cadence and totals alone; the verdict downgrades to `Unknown` when a/// pace projection is not meaningful.pub fn dividend_pace(events: &[(String, f64)], today: chrono::NaiveDate) -> DividendPace {    use chrono::Datelike;    let year = today.year();    let prior = year - 1;    let (mut prior_total, mut ytd_total, mut ytd_count) = (0.0_f64, 0.0_f64, 0_u32);    for (date, amount) in events {        let Ok(d) = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") else {            continue;        };        if d.year() == year && d <= today {            ytd_total += amount;            ytd_count += 1;        } else if d.year() == prior {            prior_total += amount;        }    }    let dates: Vec<String> = events.iter().map(|(d, _)| d.clone()).collect();    let cadence = infer_cadence(&dates);    // Count-tempered projection: scale YTD by (expected_n / declared_n_so_far).    // A quarterly payer at end-of-Q1 thus projects ×4, not ×~4 by elapsed days,    // which is what the user picked over a calendar-elapsed-fraction approach.    let (projection, pct_change, grade) = match (        cadence.expected_per_year(),        ytd_count,        prior_total,    ) {        (Some(expected), declared, prior_year) if declared > 0 && prior_year > 0.0 => {            let p = ytd_total * f64::from(expected) / f64::from(declared);            let pct = (p - prior_year) / prior_year * 100.0;            let grade = if pct > PACE_FLAT_BAND {                Grade::Good            } else if pct < -PACE_FLAT_BAND {                Grade::Bad            } else {                Grade::Ok            };            (Some(p), Some(pct), grade)        }        _ => (None, None, Grade::Unknown),    };    DividendPace {        cadence,        cadence_caption: cadence.caption(),        prior_year_total: prior_total,        ytd_total,        ytd_count,        projection,        pct_change,        grade,        verdict: grade.verdict(),    }}// ─────────────────────── company standing (Phase 20) ───────────────────────//// A stock's overall standing rolls its nine graded ratios into a single
modified src/models.rs
@@ -31,6 +31,9 @@ pub struct SymbolRow {    pub fund_synced_at: Option<i64>,    /// When this stock's leadership roster was last refreshed from SEC.    pub leadership_synced_at: Option<i64>,    /// When this stock's dividend history was last refreshed from Yahoo    /// (Phase 26). NULL for non-stocks and stocks not yet swept.    pub dividends_synced_at: Option<i64>,    pub last_price: Option<f64>,    pub prev_close: Option<f64>,    pub last_quote_at: Option<i64>,
modified src/providers/mod.rs
@@ -239,6 +239,25 @@ pub struct PortfolioData {    pub asset_mix: Vec<(String, f64)>,}// ── dividend events (Phase 26) ─────────────────────────────────────────────//// Per-payout dividend history comes from Yahoo's chart endpoint, which carries// an `events.dividends` series alongside the price bars when asked for// `events=div`. SEC XBRL's `DividendsPerShare` (already in `fundamentals`) is// per fiscal period, not per payout date, so it does not stand in. The fetch// lives on `YahooProvider` as an inherent method (one source); the type sits// here next to `Quote`/`IntradayBar` for the scheduler and routes./// One declared dividend payment, as carried by Yahoo's chart event series.#[derive(Debug, Clone)]pub struct DividendEvent {    /// Ex-dividend date, `YYYY-MM-DD`. The first trading day a new buyer does    /// NOT receive the upcoming payment — Yahoo timestamps each event by it.    pub ex_date: String,    /// Per-share amount, in the symbol's reporting currency.    pub amount: f64,}/// An upstream rejected a request with an explicit rate-limit signal (HTTP 429/// or 503). A provider returns this as the source of its `anyhow::Error` so the/// `EndpointGuard` (see `src/guard.rs`) can recognise it by downcast and trip
modified src/providers/yahoo.rs
@@ -16,7 +16,7 @@ use async_trait::async_trait;use reqwest::{header::RETRY_AFTER, StatusCode};use serde::Deserialize;use crate::providers::{IntradayBar, Quote, QuoteData, QuoteProvider, RateLimited};use crate::providers::{DividendEvent, IntradayBar, Quote, QuoteData, QuoteProvider, RateLimited};/// Near-real-time quotes from Yahoo Finance.pub struct YahooProvider {
@@ -92,6 +92,27 @@ struct ChartResult {    /// Bar-start times, Unix seconds. Absent when the day has no bars yet.    timestamp: Option<Vec<i64>>,    indicators: Indicators,    /// `events.dividends` carries declared payouts when the request asked for    /// `events=div` (Phase 26). Absent on a routine quote fetch.    events: Option<ChartEvents>,}/// The events block of a Yahoo chart payload. Each value of `dividends` is/// keyed by the event's Unix-second timestamp (a JSON string, which is why/// the outer type is a map).#[derive(Default, Deserialize)]struct ChartEvents {    #[serde(default)]    dividends: std::collections::HashMap<String, ChartDividend>,}#[derive(Deserialize)]struct ChartDividend {    /// Per-share amount.    amount: f64,    /// Ex-dividend date as a Unix second. Yahoo also echoes the timestamp as    /// the map key, but the inner field is the canonical one to read off.    date: i64,}#[derive(Deserialize)]
@@ -184,6 +205,77 @@ impl YahooProvider {            .and_then(|mut r| if r.is_empty() { None } else { Some(r.remove(0)) }))    }    /// Fetch the declared dividend history for `ticker` (Phase 26).    ///    /// The same v8 chart endpoint that serves quotes carries an    /// `events.dividends` series when the request asks for `events=div`. Ask    /// for a five-year window at daily granularity: that is plenty for the    /// page's prior-year + YTD totals and a long history list, while keeping    /// the payload modest (the candle stream itself is discarded here — only    /// the events block is parsed). Returns the payouts oldest first.    ///    /// Error semantics mirror [`Self::quote`]: a 429/503 surfaces as    /// [`RateLimited`] so the endpoint guard trips at once; an unknown symbol    /// (404 or `chart.error`) returns an empty vec, not an error, since the    /// guard should not treat it as a transport failure.    pub async fn dividends(&self, ticker: &str) -> Result<Vec<DividendEvent>> {        let sym = urlencoding::encode(&yahoo_symbol(ticker)).into_owned();        let url = format!(            "https://query1.finance.yahoo.com/v8/finance/chart/{sym}\             ?interval=1d&range=5y&events=div"        );        let resp = self.client.get(&url).send().await?;        let status = resp.status();        if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE {            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(Vec::new());        }        let resp = resp.error_for_status()?;        let env: ChartEnvelope = resp.json().await?;        if env.chart.error.is_some() {            return Ok(Vec::new());        }        let Some(result) = env            .chart            .result            .and_then(|mut r| if r.is_empty() { None } else { Some(r.remove(0)) })        else {            return Ok(Vec::new());        };        let mut out: Vec<DividendEvent> = result            .events            .unwrap_or_default()            .dividends            .into_values()            .filter_map(|d| {                // A non-positive amount or a nonsense timestamp is filtered;                // Yahoo has occasionally emitted a literal 0 placeholder.                if d.amount <= 0.0 {                    return None;                }                let ex_date = chrono::DateTime::from_timestamp(d.date, 0)?                    .format("%Y-%m-%d")                    .to_string();                Some(DividendEvent {                    ex_date,                    amount: d.amount,                })            })            .collect();        out.sort_by(|a, b| a.ex_date.cmp(&b.ex_date));        Ok(out)    }    /// 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.
modified src/routes/health.rs
@@ -239,6 +239,10 @@ fn job_meta(job: &str) -> (&str, &str) {            "Fundamentals & filings",            "SEC EDGAR company facts and filing history for each stock, refreshed weekly.",        ),        "dividends" => (            "Dividend payouts",            "Per-payout dividend history from Yahoo for each stock, refreshed weekly.",        ),        other => (other, ""),    }}
@@ -249,8 +253,9 @@ fn job_rank(job: &str) -> u8 {        "seed" => 0,        "history" => 1,        "sec" => 2,        "intraday" => 3,        "daily_close" => 4,        "dividends" => 3,        "intraday" => 4,        "daily_close" => 5,        _ => 9,    }}
modified src/routes/symbols.rs
@@ -7,7 +7,7 @@ use axum::{    routing::{get, post},    Json, Router,};use chrono::Datelike;use chrono::Datelike as _;use serde::{Deserialize, Serialize};use crate::compute;
@@ -534,6 +534,109 @@ fn build_fund(profile: FundProfileRow, holdings: Vec<HoldingRow>) -> FundView {    }}// ── dividend payouts (Phase 26) ────────────────────────────────────────────/// One dividend payment, shaped for the page.#[derive(Serialize)]struct DividendRow {    /// Ex-dividend date, `YYYY-MM-DD` (the template's `shortdate` filter    /// formats it for display).    ex_date: String,    /// Per-share amount, formatted as plain dollars, e.g. `$0.24`.    amount: String,}/// Everything the symbol page's Dividends section needs.#[derive(Serialize)]struct DividendsView {    /// Whether the Yahoo dividend sweep has reached this stock yet — picks the    /// "not synced yet" pending note apart from a genuine no-dividends history.    synced: bool,    /// The inferred pace read: cadence, prior-year and YTD totals, projection,    /// and the on-track grade.    pace: compute::DividendPace,    /// Prior-year total per share, formatted, e.g. `$0.92`. Empty string when    /// there were no payouts in the prior calendar year.    prior_year_display: String,    /// YTD total per share, formatted.    ytd_display: String,    /// Calendar year YTD belongs to (e.g. `2026`).    current_year: i32,    /// Projected current-year total, formatted; `None` when the projection is.    projection_display: Option<String>,    /// Signed percent change vs prior year, e.g. `+4.3%`; `None` when the    /// projection is.    pct_change_display: Option<String>,    /// All payouts on file, newest first.    history: Vec<DividendRow>,}/// Load the Dividends section for a stock (PLAN.md Phase 26). Returns `None`/// when there is nothing to show *and* the sweep has already run: a stock that/// pays no dividend gets no section. A pending stock (sweep has not reached it/// yet) still returns a `DividendsView` so the template can render the "not/// synced yet" note in place.async fn build_dividends(    pool: &sqlx::SqlitePool,    ticker: &str,    synced: bool,) -> Option<DividendsView> {    // Newest first for the per-event history; the pace math wants oldest first.    let rows: Vec<(String, f64)> = sqlx::query_as(        "SELECT ex_date, amount FROM dividends WHERE ticker = ? ORDER BY ex_date DESC",    )    .bind(ticker)    .fetch_all(pool)    .await    .unwrap_or_default();    // Pending sweep on a stock with no payouts yet — show the pending note.    if rows.is_empty() && !synced {        let pace = compute::dividend_pace(&[], chrono::Utc::now().date_naive());        return Some(DividendsView {            synced: false,            pace,            prior_year_display: String::new(),            ytd_display: String::new(),            current_year: chrono::Utc::now().date_naive().year(),            projection_display: None,            pct_change_display: None,            history: Vec::new(),        });    }    // A swept stock with no payouts pays no dividend — hide the section    // entirely rather than render a heading over an empty table.    if rows.is_empty() {        return None;    }    let oldest_first: Vec<(String, f64)> = rows.iter().rev().cloned().collect();    let pace = compute::dividend_pace(&oldest_first, chrono::Utc::now().date_naive());    // Per-share dividends are usually quoted to the cent; monthly REITs sometimes    // pay sub-cent amounts (e.g. `$0.0625`), so a sub-cent figure widens to 4dp.    let fmt_div = |v: f64| if v < 0.01 { format!("${v:.4}") } else { format!("${v:.2}") };    let history: Vec<DividendRow> = rows        .iter()        .map(|(d, a)| DividendRow {            ex_date: d.clone(),            amount: fmt_div(*a),        })        .collect();    // Totals and the projection are annual sums of those per-share amounts;    // keep the same precision rule so a small payout's effect is not rounded off.    let fmt_money = fmt_div;    Some(DividendsView {        synced,        prior_year_display: fmt_money(pace.prior_year_total),        ytd_display: fmt_money(pace.ytd_total),        projection_display: pace.projection.map(fmt_money),        pct_change_display: pace.pct_change.map(|p| format!("{p:+.1}%")),        current_year: chrono::Utc::now().date_naive().year(),        history,        pace,    })}// ── company leadership (Phase 14) ──────────────────────────────────────────/// A `leadership` row as stored.
@@ -859,6 +962,15 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        None    };    // Dividend payouts (Phase 26): stocks only. A stock that pays no dividend    // (or whose sweep returned nothing) gets no section; an unswept stock    // shows a pending note in place.    let dividends = if is_stock {        build_dividends(&state.pool, &ticker, symbol.dividends_synced_at.is_some()).await    } else {        None    };    let extra = minijinja::context! {        title => ticker,        symbol => symbol,
@@ -868,6 +980,7 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        standing => standing,        fund => fund,        leadership => leadership,        dividends => dividends,        filings => filings,    };    render(&state, "pages/symbol.html", &format!("/s/{ticker}"), extra)
modified src/scheduler.rs
@@ -35,8 +35,9 @@ use crate::market;use crate::providers::sec::SecProvider;use crate::providers::yahoo::YahooProvider;use crate::providers::{    self, stooq::StooqProvider, Fact, FilingRecord, FundId, FundShape, FundamentalsProvider,    HistoryProvider, IntradayBar, OwnershipPerson, PortfolioData, Quote, QuoteProvider,    self, stooq::StooqProvider, DividendEvent, Fact, FilingRecord, FundId, FundShape,    FundamentalsProvider, HistoryProvider, IntradayBar, OwnershipPerson, PortfolioData, Quote,    QuoteProvider,};use crate::stream::{Hub, QuoteUpdate, StreamEvent};use crate::{seed, Config};
@@ -89,6 +90,13 @@ const SEC_BUDGET: i64 = 600;const LEADERSHIP_STALE_SECS: i64 = 30 * 24 * 3600;const LEADERSHIP_MAX_FILINGS: usize = 30;/// Dividend history refresh (Phase 26). Declared dividends are confirmed and/// stable once an ex-date passes, so the data changes slowly: a stock pays out/// at most a handful of times a year. A weekly cadence is plenty to land each/// new payment within a few days while keeping the Yahoo budget light.const DIVIDENDS_INTERVAL_SECS: i64 = 24 * 3600;const DIVIDENDS_STALE_SECS: i64 = 7 * 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<()> {
@@ -128,6 +136,14 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle            tracing::warn!("[scheduler] bring sec job forward: {e}");        }        // Dividends job (Phase 26): bring it forward the same way the SEC job        // is, so a deploy adding the table backfills the universe within a        // tick rather than waiting out the daily interval. The sweep is        // resumable and the no-stale fast path is free.        if let Err(e) = schedule_next(&pool, "dividends", now_ms()).await {            tracing::warn!("[scheduler] bring dividends 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;
@@ -175,6 +191,20 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle                }            }            // Dividend payouts (Phase 26): sweep stocks whose dividend            // history has gone stale (weekly). Runs only when Yahoo is            // reachable; gated nowhere else — declared dividends drift after            // the ex-date passes, so a fresh pull every week is enough.            match is_due(&pool, "dividends", now_ms()).await {                Ok(true) => {                    if let Err(e) = run_dividends(&pool, &config, &hub).await {                        tracing::warn!("[scheduler] dividends: {e:#}");                    }                }                Ok(false) => {}                Err(e) => tracing::warn!("[scheduler] dividends 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
@@ -963,6 +993,139 @@ async fn run_sec(pool: &SqlitePool, config: &Config, hub: &Hub) -> anyhow::Resul    Ok(())}/// Dividend payout sweep (Phase 26).////// For every stock whose dividend history is stale, ask Yahoo for the last/// five years of declared dividends and upsert them. Stocks only — ETFs,/// indexes and futures do not pay regular dividends in this app's sense; an/// ETF's distributions live in the fund profile, not here. Routed through the/// shared `yahoo` `EndpointGuard` so it shares pacing and the per-hour budget/// with the intraday and daily-close jobs.////// Resumable in the same way as the SEC job: each stock's/// `dividends_synced_at` is stamped only on a successful fetch, so a guard/// stop leaves the rest for the next cycle.async fn run_dividends(pool: &SqlitePool, config: &Config, hub: &Hub) -> anyhow::Result<()> {    let started = now_ms();    let next = started + DIVIDENDS_INTERVAL_SECS * 1000;    let cutoff = started - DIVIDENDS_STALE_SECS * 1000;    let stale: Vec<String> = sqlx::query_scalar(        "SELECT ticker FROM symbols \         WHERE kind = 'stock' \           AND (dividends_synced_at IS NULL OR dividends_synced_at < ?) \         ORDER BY ticker",    )    .bind(cutoff)    .fetch_all(pool)    .await?;    if stale.is_empty() {        // The fast path: nothing stale, nothing to do. No fetching banner.        mark_ok(pool, "dividends", Some(next)).await?;        return Ok(());    }    mark_fetching(pool, "dividends").await?;    notify_health(hub);    tracing::info!("[scheduler] dividends: 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 payouts = 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.dividends(ticker).await {            Ok(events) => {                guard.record_success().await?;                payouts += events.len() as i64;                if let Err(e) = store_dividends(pool, ticker, &events).await {                    tracing::warn!("[scheduler] dividends store {ticker}: {e:#}");                    errors += 1;                    continue;                }                mark_dividends_synced(pool, ticker).await?;                ok += 1;            }            Err(e) => {                guard.record_failure(&e).await?;                errors += 1;                tracing::warn!("[scheduler] dividends {ticker}: {e:#}");            }        }    }    let dur = t0.elapsed().as_millis() as i64;    let detail = format!("{ok}/{} stocks, {payouts} payouts, {errors} errors", stale.len());    match stopped {        Some(why) => {            let full = format!("stopped early ({why}); {detail}");            tracing::warn!("[scheduler] dividends: {full}");            log_fetch(pool, "dividends", "yahoo", "skipped", Some(&full), Some(ok), dur, started)                .await?;        }        None => {            tracing::info!("[scheduler] dividends: {detail}");            log_fetch(pool, "dividends", "yahoo", "ok", Some(&detail), Some(ok), dur, started)                .await?;        }    }    mark_ok(pool, "dividends", Some(next)).await?;    notify_health(hub);    Ok(())}/// Replace one stock's dividend history with what Yahoo returned. Yahoo serves/// the canonical, corrected history each call, so a `DELETE` + `INSERT` keeps/// the table honest if a payout is later retracted or restated. `pub(crate)`:/// the add-symbol backfill reuses it.pub(crate) async fn store_dividends(    pool: &SqlitePool,    ticker: &str,    events: &[DividendEvent],) -> sqlx::Result<()> {    let mut tx = pool.begin().await?;    sqlx::query("DELETE FROM dividends WHERE ticker = ?")        .bind(ticker)        .execute(&mut *tx)        .await?;    for e in events {        sqlx::query(            "INSERT INTO dividends (ticker, ex_date, amount) VALUES (?, ?, ?) \             ON CONFLICT(ticker, ex_date) DO UPDATE SET amount = excluded.amount",        )        .bind(ticker)        .bind(&e.ex_date)        .bind(e.amount)        .execute(&mut *tx)        .await?;    }    tx.commit().await?;    Ok(())}/// Stamp a stock as freshly dividend-synced.async fn mark_dividends_synced(pool: &SqlitePool, ticker: &str) -> sqlx::Result<()> {    let now = now_ms();    sqlx::query("UPDATE symbols SET dividends_synced_at = ?, updated_at = ? WHERE ticker = ?")        .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> {
@@ -1347,6 +1510,11 @@ async fn guarded<T>(/// the normal scheduler sweeps pick up whatever this run missed.pub(crate) async fn backfill_symbol(pool: &SqlitePool, config: &Config, ticker: &str, kind: &str) {    backfill_history(pool, config, ticker, kind).await;    // Dividends are stocks-only and ride the Yahoo guard, not SEC, so they    // run independently of the SEC contact-email gate below.    if kind == "stock" {        backfill_dividends(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`
@@ -1363,6 +1531,26 @@ pub(crate) async fn backfill_symbol(pool: &SqlitePool, config: &Config, ticker:    }}/// Pull and store a freshly-added stock's dividend history (Phase 26). Stocks/// only — the caller already filters; routed through the same `yahoo` guard/// the dividends sweep uses. Best-effort: a guard denial or upstream error/// leaves the stock for the next normal sweep.async fn backfill_dividends(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.dividends(ticker)).await {        Some(Ok(events)) => match store_dividends(pool, ticker, &events).await {            Ok(()) => {                let _ = mark_dividends_synced(pool, ticker).await;                tracing::info!("[backfill] {ticker} <- {} dividends", events.len());            }            Err(e) => tracing::warn!("[backfill] store dividends {ticker}: {e:#}"),        },        Some(Err(e)) => tracing::warn!("[backfill] dividends {ticker}: {e:#}"),        None => {}    }}/// Pull and store one symbol's deep daily history from Stooq. A no-op for a/// future (Stooq carries no `=F` history) or when Stooq is not configured.async fn backfill_history(pool: &SqlitePool, config: &Config, ticker: &str, kind: &str) {
modified templates/pages/symbol.html
@@ -247,6 +247,59 @@  </section>  {% endif %}  {# --- dividends: payout history + on-track pace (Phase 26) --- #}  {% if dividends %}  <h2 class="section-title">Dividends{% if symbol.dividends_synced_at %}<span class="section-title__asof">synced from Yahoo {{ symbol.dividends_synced_at|ago }}</span>{% endif %}</h2>  {% if dividends.synced and dividends.history %}  <section class="panel div-panel">    {# headline: inferred cadence + the on-track verdict pill #}    <div class="div-head">      <span class="div-cadence">{{ dividends.pace.cadence_caption }}</span>      {% if dividends.projection_display %}      <span class="vbadge vbadge--{{ dividends.pace.grade }}">{{ dividends.pace.verdict }}</span>      {% endif %}    </div>    {# pace: prior calendar year vs YTD-projected current year #}    <dl class="div-pace">      <div class="div-pace__item">        <dt class="div-pace__cap">{{ dividends.current_year - 1 }} total</dt>        <dd class="div-pace__val num">{% if dividends.pace.prior_year_total > 0 %}{{ dividends.prior_year_display }}{% else %}—{% endif %}</dd>      </div>      <div class="div-pace__item">        <dt class="div-pace__cap">{{ dividends.current_year }} so far</dt>        <dd class="div-pace__val num">{{ dividends.ytd_display }}<span class="div-pace__sub">{{ dividends.pace.ytd_count }} payment{% if dividends.pace.ytd_count != 1 %}s{% endif %}</span></dd>      </div>      {% if dividends.projection_display %}      <div class="div-pace__item">        <dt class="div-pace__cap">{{ dividends.current_year }} projected</dt>        <dd class="div-pace__val num">{{ dividends.projection_display }}<span class="div-pace__sub div-pace__sub--{{ dividends.pace.grade }}">{{ dividends.pct_change_display }} vs {{ dividends.current_year - 1 }}</span></dd>      </div>      {% endif %}    </dl>    <p class="div-pace__src">Projection scales {{ symbol.ticker }}&rsquo;s declared payments so far this year up to the inferred cadence, then compares to last year&rsquo;s total. Between payouts it is an estimate, not a guarantee.</p>    {# per-event history, newest first #}    <h3 class="div-hist__title">Payment history</h3>    <ul class="div-hist">      {% for d in dividends.history %}      <li class="div-row">        <span class="div-row__date">{{ d.ex_date|shortdate }}</span>        <span class="div-row__amt num">{{ d.amount }}</span>      </li>      {% endfor %}    </ul>  </section>  {% elif dividends.synced %}  <div class="fund-pending">{{ symbol.ticker }} has not paid a dividend in the past five years.</div>  {% else %}  <div class="fund-pending">    Dividend history for {{ symbol.ticker }} has not synced yet. It lands on the    next data refresh; see <a href="/health">data health</a>.  </div>  {% endif %}  {% endif %}  {# --- leadership: officers, board & recent changes (Phase 14) --- #}  <h2 class="section-title">Leadership{% if symbol.leadership_synced_at %}<span class="section-title__asof">synced from SEC {{ symbol.leadership_synced_at|ago }}</span>{% endif %}</h2>  {% if leadership and leadership.roster %}