@@ -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);
@@ -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
@@ -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 }}’s declared payments so far this year up to the inferred cadence, then compares to last year’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 %}