repos

Phase 17: Stock health read

8a16b143 by Isaac Bythewood · 1 month ago

Phase 17: Stock health read

A non-advice synthesis of fundamentals + price/growth trajectory +
leadership stability, surfaced as a new "Stock health" panel on the
symbol page and a Healthiest / Most concerning pair on the home
dashboard. Industry context (Phase 15) is intentionally deferred.

compute: new HealthRead + health_read(ratios, closes, recent_changes)
and a stability_grade helper. Composite is 0.55 strength / 0.30
trajectory / 0.15 stability, renormalised over the components that
land so an unsynced stock is not penalised. Stability uses a discrete
three-band score over the count of 8-K item-5.02 filings in the last
730 days (0-1 Stable, 2-3 Normal, 4+ Churning) -- lenient because
large companies routinely file ~one planned-succession 5.02 a year.

symbol page: a Stock health panel between key stats and Fundamentals
(stocks only) with three sub-rows -- fundamentals / trajectory /
leadership -- each showing the value and the actual reasoning (e.g.
"7 reported officer or director changes in the last 2 years"). The
existing Phase 20 standing badge stays in Fundamentals as the
per-ratio rollup; the new panel is the broader synthesis.

home dashboard: a Healthiest / Most concerning pair above the
strongest / weakest panels, ranking curated large-caps by the
composite. New .hrow macro shows three sub-component pills under the
name and the overall verdict on the right.

scss: new .health / .health-row block in symbol.scss (three-column
desktop grid that collapses to two-column at 480px so the note wraps
under the value), new .hrow block in home.scss. The .section-note
style moves from home.scss to base.scss so the symbol page can use it.

Pure derivation: no schema change, no new network calls, no new
EndpointGuard. Stocks only -- ETFs / indexes / futures hide the panel.
modified PLAN.md
@@ -32,7 +32,25 @@ and resume cleanly from this file alone, keeping token use low._Last updated: 2026-05-23_**Current phase: Phase 16 (per-ticker anomaly feed) complete, verified,**Current phase: Phase 17 (stock health read) complete and verifiedlocally 2026-05-23 — not yet deployed.** A single non-advice "health" readper stock layered over the Phase 20 strength + trajectory composite: itfolds in a leadership-stability signal read off the recent 8-K item-5.02change count (Phase 14). Industry context (Phase 15) was intentionallydropped from this phase — the read ships without it and a later pass canlayer it on. Two surfaces. On the symbol page a new "Stock health" panelsits between key stats and Fundamentals (stocks only): an overallHealthy / Mixed / Concerning verdict plus three sub-readings —fundamentals (Strong / Fair / Weak), trajectory (Climbing / Steady /Slipping), leadership (Stable / Normal / Churning) with the actualrecent-change count. On the home dashboard a new "Stock health" pair ofpanels (Healthiest / Most concerning) ranks the curated large-caps by thecomposite, mirroring the Phase 20 strongest/weakest layout but with thethree sub-component chips visible on each row. Both surfaces carry aquiet "for fun and reading at a glance, not investment advice"disclaimer. Pure derivation from data already stored — no new networkcalls, no schema change, no new `EndpointGuard` row. **Phase 16(per-ticker anomaly feed) complete, verified,and deployed to production 2026-05-23 (commit `a839737`).** A new symbol-page section betweenLeadership and Recent SEC filings, surfacing notable recent events: largedaily price moves, drawdowns (new 6-month lows), large YoY fundamentals
@@ -81,8 +99,9 @@ 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. Deployed to production 2026-05-22 (commit `7608b06`). Remainingpost-MVP work: the loose-ordered Phase 13, 15, 16, 17, 19, 25, 27, 29backlog (Phase 28 is now in progress). Phase 28 (ETFs as first-classpost-MVP work: the loose-ordered Phase 13, 15, 19, 25, 27, 29backlog (Phases 16, 17 and 28 are now done; 28 is shipped, 16 isshipped, 17 is verified locally and pending deploy). Phase 28 (ETFs as first-classcitizens) was picked as the next backlog phase and scoped 2026-05-22 (seethe decisions log): one big phase covering all seven pieces (distributions,expense ratio + yield, NAV / premium-discount, sector + geography, full
@@ -107,6 +126,73 @@ backlog as Phase 19. The `watchlists` / `watchlist_items` tables stay in theschema, unused for now.**Done**- **Phase 17 stock health read.** Complete and verified locally  2026-05-23 — not yet deployed. A non-advice synthesis of the data this  app already carries (fundamentals + price/growth trajectory + leadership  stability), surfaced two ways: a "Stock health" panel on the stock  symbol page and a "Healthiest / Most concerning" pair on the home  dashboard. Industry context was intentionally dropped from this phase  (Phase 15 is not built; see decisions log) — the read ships without it  and a later pass can layer it on.  - **Compute** added `HealthRead` and `health_read(ratios, closes,    recent_changes)` in `compute.rs`, plus a `stability_grade` helper.    HealthRead carries the overall grade + verdict, the composite score    (for ranking), and three sub-component grades + labels — fundamentals    (Strong / Fair / Weak, from `graded_mean` over the nine ratios),    trajectory (Climbing / Steady / Slipping, from `trajectory_score`),    and leadership stability (Stable / Normal / Churning, from a discrete    band over the change count). Composite weighting: 0.55 strength /    0.30 trajectory / 0.15 stability, renormalised over the components    that landed so an unsynced leadership stat does not penalise the    stock. Stability bands: 0-1 changes in the last 730 days reads    Stable / +1.0; 2-3 Normal / 0.0; 4+ Churning / -1.0 — deliberately    lenient since big companies routinely file ~one planned-succession    5.02 a year. Each piece is pure; `LEADERSHIP_STABILITY_DAYS` and    `MIN_GRADED` from Phase 20 stay the same gates.  - **Symbol page wiring** in `routes/symbols.rs`. Hoisted the daily-close    series out of the standing block so the new health read can share it.    Added one small SELECT for the 8-K item-5.02 count over the last    730 days (only fired once leadership has synced; `None` otherwise so    the composite drops the stability term cleanly). `build_anomalies` and    the existing Phase 20 standing computation were left intact — the new    health read is additive, not a replacement.  - **Symbol template** got a new `{% if health %}` panel between the key    stats and Fundamentals (stocks only), introducing the section with a    quiet section note + a `.disclaimer` line. Three `.health-row` rows    carry the sub-component label / value / note; the per-row left    border + value colour are semantically green / amber / red. The    Phase 20 standing badge stays in the Fundamentals section above the    ratio cards — it remains the per-ratio rollup; the new panel is the    broader synthesis.  - **Home page wiring** in `routes/home.rs`. `StockRow` gained    `health: Option<HealthRead>` and a `leadership_synced_at` field.    `load_stocks` got a fourth bulk query — one `GROUP BY ticker` of the    8-K item-5.02 filings inside `LEADERSHIP_STABILITY_DAYS`, joined to    `is_seeded` stocks — that feeds each stock's HealthRead. A new    `health_panels()` ranks by composite score, takes the top and bottom 8,    and scales each row's magnitude tint to the largest absolute score    across both panels (mirroring the movers / standing tint maths).  - **Home template** picked up a "Stock health" section above the    existing "Strongest & weakest" section: section note + disclaimer +    a Healthiest / Most concerning panel pair. Each row uses a new    `health_row` macro and a new `.hrow` style: ticker, then the company    name with three sub-component pills underneath (the same green /    amber / red, smaller eyebrow type), then the overall verdict badge.  - **SCSS.** New `.health` / `.health-row` block in `symbol.scss` (a    three-column desktop grid that collapses to two-column on phones    via a 480px media query, so the note wraps under the value); new    `.hrow` / `.hrow__sub` / `.hrow__chip` block in `home.scss`. The    shared `.section-note` style moved from `home.scss` to `base.scss`    so the symbol page can use it too.  - Verified: `cargo check` + `bun run build` clean. `/s/AAPL` reads    `Mixed` overall — Fair fundamentals, Climbing trajectory, Churning    leadership at 7 reported changes in the last 2 years; the    Fundamentals section below still carries its own `FAIR` standing    badge. `/s/SPY` (ETF) and `/s/^SPX` (index) hide the section cleanly.    `/` renders the Stock health panel above Strongest & weakest with    GOOGL, GOOG and MU leading the healthiest list. Desktop (1280px)    and phone (390px) both render with no horizontal overflow; zero    console errors on the home page or any symbol page checked.- **Phase 0 skeleton** — complete, verified. `make run` serves the dark  futuristic dashboard on port 8000, migrations apply on boot, routes plus a  themed 404 work, the request log is colored.
@@ -1430,14 +1516,18 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data).  reads consistently. Section hides cleanly on an empty feed.  Pure derivation: no schema change, no new network calls, no new  `EndpointGuard` row.- [ ] **Phase 17: Stock health read.** Synthesize fundamentals, price  trajectory, leadership, and industry context into a single non-advice  "health" read: is this a healthy company (capable leadership familiar with  the industry, solid fundamentals, consistent gains with the occasional  acceptable setback)? Explicitly NOT buy or sell advice, and labelled as such  in the UI. Builds on Phase 20 (its composite fundamental-strength grade and  trajectory measure are the core of the read), layering Phase 14 leadership  and Phase 15 industry context on top. Builds on Phases 7, 14, 15 and 20.- [x] **Phase 17: Stock health read.** Complete and verified locally  2026-05-23 (not yet deployed) — see the Phase 17 Done entry in Status  and the decisions log. Synthesizes fundamentals + price/growth  trajectory + leadership stability into a single non-advice "health"  read, on the stock symbol page and as a Healthiest / Most concerning  pair on the home dashboard. Industry context (Phase 15) was  intentionally dropped from this phase since Phase 15 is not built; the  read ships without it and a later pass can layer it on. The leadership  signal is a stability score read off the count of recent 8-K item-5.02  filings (the same Phase 14 data already on the page); the user-picked  scope was "stability via churn count" over a qualitative note. Builds  on Phases 7, 14 and 20.- [x] **Phase 18: ETF profiles.** Complete and verified (2026-05-22) — the  first post-MVP phase, see the Phase 18 entry in Status and the decisions  log. ETFs are first-class: a fund profile (AUM, holdings count, top 25
@@ -2907,6 +2997,52 @@ finance/  master` (commit `a839737`), bundled with the Phase 30 rework and the  S&P 500 universe expansion that were also pending deploy from earlier  the same day.- **2026-05-23 — Phase 17 picked next; scope settled in three Q&A.** The  user picked Phase 17 (stock health read) over the remaining loose  backlog (13 heat map, 15 industry trends, 25 earnings dates, 27 backup  providers, 29 issuer-direct ETF feeds). Three scoping questions settled  before any code:  1. **Industry context** — Phase 15 (industry trends) was scheduled as a     prerequisite in the original Phase 17 scope. The user chose to     **drop industry from this phase** rather than detour through Phase     15 first or fold a minimal SIC tag inline. The health read ships     without industry context; a later pass after Phase 15 can layer it     on. The Phases-list and Done entries note this explicitly.  2. **Leadership signal** — Phase 14 ships only a roster + 8-K item-5.02     change feed (no tenure, no insider/outsider read). The user picked     a **stability score via recent change count**, not a qualitative     note alongside the composite. Discrete three-band score (0-1 →     Stable, 2-3 → Normal, 4+ → Churning) over the count of 8-K item-5.02     filings in the last 730 days — deliberately lenient because large     companies routinely file ~one planned-succession 5.02 a year.  3. **Surfaces** — the user picked **symbol page + a home rank panel**     (rather than symbol-page-only). The home panels mirror the Phase 20     strongest / weakest pair, sitting above them; the symbol-page panel     sits above Fundamentals (the existing Phase 20 standing badge stays     in place, since it is specifically the per-ratio rollup and the new     panel is the broader synthesis).- **2026-05-23 — Phase 17 stock health read shipped (local).** Pure  derivation on top of data the app already carries, no schema change  and no new network calls. The composite is fundamentals 0.55 +  trajectory 0.30 + leadership stability 0.15, renormalised over the  components that landed so an unsynced stock is not penalised; bands  use the same `STRONG_CUTOFF` / `WEAK_CUTOFF` as Phase 20 so the overall  verdict (Healthy / Mixed / Concerning) stays consistent with the  per-ratio standing (Strong / Fair / Weak). The new "Stock health"  symbol-page panel renders three sub-rows (fundamentals / trajectory /  leadership) with the actual numeric reasoning beside each (e.g. "7  reported officer or director changes in the last 2 years"); the  Fundamentals section below still carries its own standing badge —  intentional, since the two read at different levels (per-ratio rollup  vs broader synthesis) and the user wanted the synthesis at the top.  The home Stock health pair mirrors strongest / weakest layout with a  new `health_row` macro showing the three sub-component pills under  the name and the overall verdict on the right. Verified: cargo + bun  build clean; `/s/AAPL` reads Mixed (Fair / Climbing / Churning at 7  changes), `/s/SPY` and `/s/^SPX` hide the panel cleanly; `/` renders  the Healthiest panel with GOOGL / GOOG / MU leading; desktop and  390px phone both render with no horizontal overflow and zero console  errors. Not yet deployed.---
modified frontend/static_src/base/styles/base.scss
@@ -645,6 +645,17 @@ main {  background: var(--down-soft);}/* ---------- intro note under a section title ---------- *//* A quiet ink-faint line, scoped wider than a chip caption. Used on the home   panels and the symbol-page Stock health panel (Phase 17) to introduce a   section in one short sentence without crowding the title. */.section-note {  margin: -2px 0 14px;  max-width: 64ch;  color: var(--ink-faint);  font-size: 0.82rem;}/* ---------- disclaimer line (Phase 30) ---------- *//* A quiet ink-faint line used on the home Top picks panel and the /backtest   page header. Always smaller than body text; never carries a semantic
modified frontend/static_src/home/styles/home.scss
@@ -26,14 +26,6 @@  text-underline-offset: 2px;}/* ---------- intro note under a section title ---------- */.section-note {  margin: -2px 0 14px;  max-width: 64ch;  color: var(--ink-faint);  font-size: 0.82rem;}/* ---------- sparkline cards ---------- */.spark-grid {  display: grid;
@@ -336,6 +328,108 @@  min-width: 5ch;}/* ---------- healthiest / most-concerning panels (Phase 17) ---------- *//* Each row carries the symbol, the name with three sub-component chips   underneath (fundamentals / trajectory / leadership), and the overall   Healthy / Mixed / Concerning verdict on the right. The chips read as   coloured pills so the synthesis is legible without expanding. */.hrow {  position: relative;  display: grid;  grid-template-columns: auto minmax(0, 1fr) auto;  align-items: center;  gap: 12px;  padding: 10px 6px;  color: var(--ink);  border-bottom: 1px solid var(--rule);}.hrow:last-child {  border-bottom: 0;}/* the magnitude tint behind the row, mirroring .standing */.hrow::before {  content: "";  position: absolute;  left: 0;  top: 0;  bottom: 0;  width: var(--bar, 0%);  border-radius: var(--radius-sm);}.hrow--up::before {  background: var(--up-soft);}.hrow--down::before {  background: var(--down-soft);}.hrow > * {  position: relative;}.hrow:hover {  color: var(--ink);  background: var(--well);}.hrow__sym {  font-weight: 700;  font-size: 0.86rem;}.hrow__body {  display: flex;  flex-direction: column;  gap: 4px;  min-width: 0;}.hrow__name {  font-size: 0.78rem;  color: var(--ink-dim);  white-space: nowrap;  overflow: hidden;  text-overflow: ellipsis;}.hrow__sub {  display: flex;  flex-wrap: wrap;  gap: 4px;}/* the three sub-component pills — fundamentals / trajectory / leadership */.hrow__chip {  @include eyebrow;  padding: 1px 6px;  border-radius: 999px;  font-size: 0.48rem;}.hrow__chip--good {  color: var(--up);  background: var(--up-soft);}.hrow__chip--ok {  color: var(--warn);  background: var(--warn-soft);}.hrow__chip--bad {  color: var(--down);  background: var(--down-soft);}.hrow__chip--unknown {  color: var(--ink-faint);  background: var(--well);}/* ---------- top picks panel (Phase 30) ---------- *//* Four columns (Day / Week / Month / Year), each carrying 5 ranked picks.   On a phone the four stack 2-up so a quick read of all horizons stays
modified frontend/static_src/symbol/styles/symbol.scss
@@ -380,6 +380,112 @@  background: var(--down);}/* ---------- stock health read (Phase 17) ---------- *//* A synthesis panel above the per-component data sections: an overall   Healthy / Mixed / Concerning verdict, then the three sub-readings   (fundamentals / trajectory / leadership). Each sub-row carries the   semantic colour through a left border and a coloured value, keeping   the panel readable at a glance without overwhelming the page. */.health {  padding: 14px 16px;}.health__overall {  display: flex;  align-items: center;  gap: 12px;  padding-bottom: 12px;  margin-bottom: 10px;  border-bottom: 1px solid var(--rule);}/* the overall verdict reads larger than the inline app-wide pill */.health__overall .vbadge {  font-size: 0.74rem;  padding: 7px 14px;}.health__overall-text {  max-width: 70ch;  color: var(--ink-dim);  font-size: 0.92rem;}.health__breakdown {  display: flex;  flex-direction: column;  gap: 8px;}.health-row {  display: grid;  grid-template-columns: 7.5rem auto minmax(0, 1fr);  align-items: baseline;  gap: 14px;  padding: 8px 12px;  border-left: 3px solid var(--rule);  background: var(--well);  border-radius: var(--radius-sm);}.health-row--good {  border-left-color: var(--up);}.health-row--ok {  border-left-color: var(--warn);}.health-row--bad {  border-left-color: var(--down);}.health-row--unknown {  border-left-color: var(--ink-faint);}.health-row__label {  @include eyebrow;  color: var(--ink);}.health-row__value {  font-weight: 700;  font-size: 0.95rem;}.health-row--good .health-row__value {  color: var(--up);}.health-row--ok .health-row__value {  color: var(--warn);}.health-row--bad .health-row__value {  color: var(--down);}.health-row--unknown .health-row__value {  color: var(--ink-faint);}.health-row__note {  font-size: 0.8rem;  color: var(--ink-faint);}@media (max-width: 480px) {  .health-row {    grid-template-columns: 1fr auto;    row-gap: 4px;  }  .health-row__note {    grid-column: 1 / -1;  }}/* ---------- fundamentals: graded ratio cards ---------- */.fund-basis {  margin: 2px 0 14px;
modified src/compute.rs
@@ -906,6 +906,152 @@ pub fn standing(ratios: &[Ratio], closes: &[f64]) -> Option<Standing> {    })}// ─────────────────────── stock health read (Phase 17) ──────────────────────//// The health read layers a leadership-stability signal over the Phase 20// strength + trajectory composite to give a single non-advice summary of// whether a stock looks healthy. Pure derivation: ratios from Phase 7, a// daily-close series, and a count of recent 8-K item-5.02 leadership changes// from Phase 14. Industry context (Phase 15) is intentionally not folded in// yet; this phase ships without it and a later pass will layer it on./// Trailing window in days for counting 8-K item-5.02 leadership changes that/// feed the stability score (~24 months). Long enough to capture an annual/// change pattern, short enough that years-old turnover ages out.pub const LEADERSHIP_STABILITY_DAYS: i64 = 730;/// Health-composite weights. Strength carries the most weight (a healthy/// company is first built well), trajectory next, stability last. They sum/// to 1; the weighting mirrors the Phase 20 user steer that the ranking/// should lean on how the company is *built* over how its price has *moved*.const HEALTH_W_STRENGTH: f64 = 0.55;const HEALTH_W_TRAJECTORY: f64 = 0.30;const HEALTH_W_STABILITY: f64 = 0.15;/// A stock's health read: an overall healthy / mixed / concerning verdict,/// the composite score the home panels rank by, and the three sub-components/// behind it so the symbol page can show the breakdown.#[derive(Debug, Clone, Copy, Serialize)]pub struct HealthRead {    /// CSS hook for the overall badge: `good` | `ok` | `bad`.    pub overall: Grade,    /// Badge text derived from `overall`: `Healthy` | `Mixed` | `Concerning`.    pub verdict: &'static str,    /// Composite score in [-1, 1]; home panels sort by it.    pub score: f64,    pub strength: Grade,    pub strength_label: &'static str,    pub trajectory: Grade,    pub trajectory_label: &'static str,    pub stability: Grade,    pub stability_label: &'static str,    /// 8-K item-5.02 filings counted inside `LEADERSHIP_STABILITY_DAYS`.    pub recent_changes: usize,}/// Map an overall health grade to its display verdict. Distinct from the/// per-ratio `Strong/Fair/Weak` so the panel reads as a synthesis, not a/// ratio rollup.fn health_verdict(g: Grade) -> &'static str {    match g {        Grade::Good => "Healthy",        Grade::Ok => "Mixed",        Grade::Bad => "Concerning",        Grade::Unknown => "Unread",    }}/// Trajectory sub-component label.fn trajectory_label(g: Grade) -> &'static str {    match g {        Grade::Good => "Climbing",        Grade::Ok => "Steady",        Grade::Bad => "Slipping",        Grade::Unknown => "—",    }}/// Leadership-stability sub-component label.fn stability_label(g: Grade) -> &'static str {    match g {        Grade::Good => "Stable",        Grade::Ok => "Normal",        Grade::Bad => "Churning",        Grade::Unknown => "—",    }}/// Grade leadership stability from the count of recent 8-K item-5.02 changes/// inside `LEADERSHIP_STABILITY_DAYS`. Returns `None` when the leadership/// sweep has not reached the stock yet (the caller passes `None`), so the/// component drops out of the composite cleanly instead of penalising/// an unsynced stock. Three discrete bands rather than a linear scale: the/// signal is coarse and the bands keep it from drifting on small counts.////// - 0-1 change  → `Good` / `+1.0` — stable/// - 2-3 changes → `Ok`   / `0.0`  — normal/// - 4+ changes  → `Bad`  / `-1.0` — churn////// Big companies routinely file ~one planned-succession 5.02 a year, so the/// bands are deliberately lenient.pub fn stability_grade(recent_changes: Option<usize>) -> Option<(Grade, f64)> {    match recent_changes? {        0 | 1 => Some((Grade::Good, 1.0)),        2 | 3 => Some((Grade::Ok, 0.0)),        _ => Some((Grade::Bad, -1.0)),    }}/// Roll a stock's ratios, trajectory and leadership-change count into a/// single [`HealthRead`]. `ratios` is the output of [`compute_ratios`];/// `closes` is a daily-close series (oldest first) over roughly the trailing/// year (may be empty); `recent_changes` is the count of 8-K item-5.02/// filings in the last `LEADERSHIP_STABILITY_DAYS`, or `None` if the/// leadership sweep has not reached this stock. `None` when too few ratios/// graded to judge (same gate as [`standing`]).////// The composite renormalises over the components that landed — a stock with/// no leadership data yet is read on strength + trajectory alone, not penalised.pub fn health_read(    ratios: &[Ratio],    closes: &[f64],    recent_changes: Option<usize>,) -> Option<HealthRead> {    let strength_raw = graded_mean(ratios.iter().map(|r| r.grade), MIN_GRADED)?;    let trajectory_raw = trajectory_score(ratios, closes);    let stability_pair = stability_grade(recent_changes);    let mut weighted = HEALTH_W_STRENGTH * strength_raw;    let mut total = HEALTH_W_STRENGTH;    if let Some(t) = trajectory_raw {        weighted += HEALTH_W_TRAJECTORY * t;        total += HEALTH_W_TRAJECTORY;    }    if let Some((_, s)) = stability_pair {        weighted += HEALTH_W_STABILITY * s;        total += HEALTH_W_STABILITY;    }    let score = weighted / total;    let overall = score_grade(score);    let strength = score_grade(strength_raw);    let trajectory = trajectory_raw.map(score_grade).unwrap_or(Grade::Unknown);    let stability = stability_pair.map(|(g, _)| g).unwrap_or(Grade::Unknown);    Some(HealthRead {        overall,        verdict: health_verdict(overall),        score,        strength,        strength_label: strength.verdict(),        trajectory,        trajectory_label: trajectory_label(trajectory),        stability,        stability_label: stability_label(stability),        recent_changes: recent_changes.unwrap_or(0),    })}// ────────────────────── ETF trailing returns (Phase 28) ────────────────────//// Trailing total returns from a fund's daily-close series. Distributions are
modified src/routes/home.rs
@@ -51,6 +51,10 @@ const MOVERS_LIMIT: usize = 8;/// `MOVERS_LIMIT` so the two pairs of panels read alike.const STANDING_LIMIT: usize = 8;/// How many stocks each of the healthiest / most-concerning health panels/// lists (Phase 17). Mirrors `STANDING_LIMIT`.const HEALTH_LIMIT: usize = 8;/// A symbol's latest session counts as the bars within this window of its most/// recent intraday bar. The regular-plus-extended session spans ~16h, while/// the prior session's bars sit a full ~24h earlier, so 23h cleanly isolates
@@ -117,16 +121,32 @@ struct StandingRow {    up: bool,}/// One row in a healthiest / most-concerning panel (Phase 17). Carries the/// `HealthRead` directly so the row can show the overall verdict alongside/// the three sub-readings.#[derive(Serialize, Clone)]struct HealthRow {    ticker: String,    name: String,    health: compute::HealthRead,    /// Width (0..100) of the row's magnitude tint, scaled to the largest    /// absolute score shown across both panels (mirrors movers / standing).    bar: f64,    /// Colour hook: true when the composite score is not negative.    up: bool,}/// One curated large-cap stock with everything the home panels need: its/// price, the close it is changing against, its rolled-up standing, and its/// trailing-year return. Built once per render and fed to both the movers and/// the strongest / weakest panels./// price, the close it is changing against, its rolled-up standing, its/// trailing-year return, and the health read (Phase 17). Built once per/// render and fed to the movers, strongest / weakest, and health panels.struct StockRow {    ticker: String,    name: String,    last: Option<f64>,    prev: Option<f64>,    standing: Option<compute::Standing>,    health: Option<compute::HealthRead>,    ret_12m: Option<f64>,    /// When this stock was last quoted (epoch-ms); feeds the movers panel's    /// freshness caption (PLAN.md Phase 22).
@@ -134,6 +154,9 @@ struct StockRow {    /// When this stock's SEC fundamentals last synced (epoch-ms); feeds the    /// strongest / weakest panels' freshness caption.    fundamentals_synced_at: Option<i64>,    /// When this stock's SEC leadership roster last synced (epoch-ms); feeds    /// the health panels' freshness caption.    leadership_synced_at: Option<i64>,}async fn home(State(state): State<AppState>) -> Response {
@@ -160,6 +183,7 @@ async fn home(State(state): State<AppState>) -> Response {    let stocks = load_stocks(&state).await;    let (gainers, losers) = movers(&stocks);    let (strongest, weakest) = strength_panels(&stocks);    let (healthiest, concerning) = health_panels(&stocks);    // Top picks (Phase 30): computed live every render, so the panel works on    // day 1 before the snapshot job has run. A separate scan (LOOKBACK_DAYS is
@@ -176,6 +200,17 @@ async fn home(State(state): State<AppState>) -> Response {    // SEC fundamentals sync — the data each panel actually leans on.    let movers_asof = stocks.iter().filter_map(|s| s.last_quote_at).max();    let standings_asof = stocks.iter().filter_map(|s| s.fundamentals_synced_at).max();    // The health panels lean on both fundamentals and leadership, so their    // freshness caption tracks whichever sync ran later.    let health_asof = stocks        .iter()        .filter_map(|s| {            [s.fundamentals_synced_at, s.leadership_synced_at]                .iter()                .filter_map(|x| *x)                .max()        })        .max();    let extra = minijinja::context! {        title => "Markets",
@@ -190,6 +225,9 @@ async fn home(State(state): State<AppState>) -> Response {        strongest => strongest,        weakest => weakest,        standings_asof => standings_asof,        healthiest => healthiest,        concerning => concerning,        health_asof => health_asof,        pick_slates => pick_slates,        total => total,    };
@@ -301,17 +339,27 @@ async fn spark_cards_for(state: &AppState, tickers: &[&str]) -> SparkSection {/// excluded: only single stocks have the SEC fundamentals a standing needs.async fn load_stocks(state: &AppState) -> Vec<StockRow> {    // 1. Price per curated stock: the live last price, else the latest daily    //    close; plus the prior close it is changing against.    let price_rows: Vec<(String, String, Option<f64>, Option<f64>, Option<i64>, Option<i64>)> =        sqlx::query_as(            "SELECT s.ticker, s.name, \    //    close; plus the prior close it is changing against. Also pulls each    //    stock's fundamentals and leadership sync times (for the panels'    //    freshness captions, Phase 22 + Phase 17).    type PriceRow = (        String,        String,        Option<f64>,        Option<f64>,        Option<i64>,        Option<i64>,        Option<i64>,    );    let price_rows: Vec<PriceRow> = sqlx::query_as(        "SELECT s.ticker, s.name, \           COALESCE(s.last_price, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \           COALESCE(s.prev_close, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)), \           s.last_quote_at, s.fundamentals_synced_at \           s.last_quote_at, s.fundamentals_synced_at, s.leadership_synced_at \         FROM symbols s WHERE s.is_seeded = 1 AND s.kind = 'stock'",        )    )    .fetch_all(&state.pool)    .await    .unwrap_or_default();
@@ -357,28 +405,75 @@ async fn load_stocks(state: &AppState) -> Vec<StockRow> {        closes.entry(ticker).or_default().push(close);    }    // 4. Recent 8-K item-5.02 leadership-change count per curated stock,    //    inside the Phase 17 stability window. One bulk query: the GROUP BY    //    returns only stocks with at least one match, so the lookup misses    //    cleanly for a fully-stable company (read below as 0).    let lead_cutoff = (chrono::Utc::now().date_naive()        - chrono::Duration::days(compute::LEADERSHIP_STABILITY_DAYS))    .to_string();    let change_rows: Vec<(String, i64)> = sqlx::query_as(        "SELECT f.ticker, COUNT(*) FROM filings f JOIN symbols s ON s.ticker = f.ticker \         WHERE s.is_seeded = 1 AND s.kind = 'stock' \           AND f.form LIKE '8-K%' AND f.items LIKE '%5.02%' \           AND f.filed_at >= ? \         GROUP BY f.ticker",    )    .bind(&lead_cutoff)    .fetch_all(&state.pool)    .await    .unwrap_or_default();    let lead_counts: HashMap<String, usize> = change_rows        .into_iter()        .map(|(t, n)| (t, n.max(0) as usize))        .collect();    // Assemble: grade each stock off its facts and price, read its trajectory    // off its closes. A stock with no fundamentals stored yet simply has no    // standing and is left out of the strongest / weakest ranking.    // off its closes, and read its leadership stability off the change count.    // A stock with no fundamentals stored yet simply has no standing or health    // and is left out of the strongest / weakest and healthiest rankings.    price_rows        .into_iter()        .map(|(ticker, name, last, prev, last_quote_at, fundamentals_synced_at)| {            let stock_closes = closes.get(&ticker).map(Vec::as_slice).unwrap_or(&[]);            let standing = facts.get(&ticker).and_then(|f| {                let inputs = models::latest_annual_inputs(f, last)?;                compute::standing(&compute::compute_ratios(&inputs), stock_closes)            });            StockRow {        .map(            |(                ticker,                name,                last,                prev,                standing,                ret_12m: compute::trailing_return(stock_closes),                last_quote_at,                fundamentals_synced_at,            }        })                leadership_synced_at,            )| {                let stock_closes = closes.get(&ticker).map(Vec::as_slice).unwrap_or(&[]);                let ratios = facts.get(&ticker).and_then(|f| {                    let inputs = models::latest_annual_inputs(f, last)?;                    Some(compute::compute_ratios(&inputs))                });                let standing = ratios                    .as_ref()                    .and_then(|r| compute::standing(r, stock_closes));                // Leadership-change count is `None` until the leadership sweep                // has reached this stock; the composite then drops that                // component instead of penalising an unsynced company.                let recent_changes = leadership_synced_at                    .map(|_| lead_counts.get(&ticker).copied().unwrap_or(0));                let health = ratios                    .as_ref()                    .and_then(|r| compute::health_read(r, stock_closes, recent_changes));                StockRow {                    ticker,                    name,                    last,                    prev,                    standing,                    health,                    ret_12m: compute::trailing_return(stock_closes),                    last_quote_at,                    fundamentals_synced_at,                    leadership_synced_at,                }            },        )        .collect()}
@@ -489,3 +584,53 @@ fn strength_panels(stocks: &[StockRow]) -> (Vec<StandingRow>, Vec<StandingRow>)    }    (strongest, weakest)}/// The healthiest and most-concerning curated stocks by their Phase 17/// composite — fundamentals + trajectory + leadership stability. A broader/// read than the Phase 20 strongest / weakest pair, layering the leadership/// stability signal on top. Stocks without a health read (fundamentals not/// synced) are left out; the panels are a fixed page-load snapshot.fn health_panels(stocks: &[StockRow]) -> (Vec<HealthRow>, Vec<HealthRow>) {    let mut ranked: Vec<&StockRow> = stocks.iter().filter(|s| s.health.is_some()).collect();    if ranked.is_empty() {        return (Vec::new(), Vec::new());    }    ranked.sort_by(|a, b| {        let (sa, sb) = (a.health.unwrap().score, b.health.unwrap().score);        sb.partial_cmp(&sa).unwrap_or(Ordering::Equal)    });    let row = |s: &StockRow| {        let health = s.health.unwrap();        HealthRow {            ticker: s.ticker.clone(),            name: s.name.clone(),            health,            bar: 0.0,            up: health.score >= 0.0,        }    };    let mut healthiest: Vec<HealthRow> =        ranked.iter().copied().take(HEALTH_LIMIT).map(&row).collect();    let mut concerning: Vec<HealthRow> = ranked        .iter()        .copied()        .rev()        .take(HEALTH_LIMIT)        .map(&row)        .collect();    let max_abs = healthiest        .iter()        .chain(concerning.iter())        .map(|r| r.health.score.abs())        .fold(0.0_f64, f64::max);    for r in healthiest.iter_mut().chain(concerning.iter_mut()) {        r.bar = if max_abs > 0.0 {            (r.health.score.abs() / max_abs * 100.0).clamp(0.0, 100.0)        } else {            0.0        };    }    (healthiest, concerning)}
modified src/routes/symbols.rs
@@ -1135,9 +1135,42 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)    // rolled up, with the daily-close trajectory folded into its score. Shown    // as a single badge over the ratio cards. `bars` is newest-first, so it is    // reversed into an oldest-first close series.    let standing = fundamentals.as_ref().and_then(|f| {        let closes: Vec<f64> = bars.iter().rev().map(|b| b.4).collect();        compute::standing(&f.ratios, &closes)    let closes_oldest_first: Vec<f64> = if is_stock {        bars.iter().rev().map(|b| b.4).collect()    } else {        Vec::new()    };    let standing = fundamentals        .as_ref()        .and_then(|f| compute::standing(&f.ratios, &closes_oldest_first));    // The stock health read (Phase 17): the ratios + trajectory of the    // standing above, plus a leadership-stability signal read off the recent    // 8-K item-5.02 change count from Phase 14. `None` until the leadership    // sweep has reached this stock; the composite then drops that component    // cleanly instead of penalising an unsynced stock. Stocks only.    let leadership_changes_recent: Option<usize> = if is_stock        && symbol.leadership_synced_at.is_some()    {        let cutoff = (chrono::Utc::now().date_naive()            - chrono::Duration::days(compute::LEADERSHIP_STABILITY_DAYS))        .to_string();        sqlx::query_scalar::<_, i64>(            "SELECT COUNT(*) FROM filings \             WHERE ticker = ? AND form LIKE '8-K%' AND items LIKE '%5.02%' \               AND filed_at >= ?",        )        .bind(&ticker)        .bind(&cutoff)        .fetch_one(&state.pool)        .await        .ok()        .map(|n| n.max(0) as usize)    } else {        None    };    let health = fundamentals.as_ref().and_then(|f| {        compute::health_read(&f.ratios, &closes_oldest_first, leadership_changes_recent)    });    let filings: Vec<FilingView> = if is_stock || is_etf {
@@ -1272,6 +1305,7 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        quote => quote,        fundamentals => fundamentals,        standing => standing,        health => health,        fund => fund,        fund_meta => fund_meta,        returns => returns,
modified templates/includes/macros.html
@@ -117,3 +117,23 @@  {{ verdict_badge(r.standing) }}</a>{% endmacro %}{# `health_row` renders one healthiest / most-concerning row (Phase 17). The   overall verdict badge sits on the right; the three sub-readings ride below   the name as a compact line so the synthesis is legible without expanding.   `--bar` sizes the magnitude tint exactly like the standing rows. #}{% macro health_row(r) %}<a class="hrow {{ 'hrow--up' if r.up else 'hrow--down' }}"   href="/s/{{ r.ticker|urlencode }}" style="--bar: {{ r.bar }}%">  <span class="hrow__sym num">{{ r.ticker }}</span>  <span class="hrow__body">    <span class="hrow__name">{{ r.name }}</span>    <span class="hrow__sub">      <span class="hrow__chip hrow__chip--{{ r.health.strength }}">{{ r.health.strength_label }}</span>      <span class="hrow__chip hrow__chip--{{ r.health.trajectory }}">{{ r.health.trajectory_label }}</span>      <span class="hrow__chip hrow__chip--{{ r.health.stability }}">{{ r.health.stability_label }}</span>    </span>  </span>  {{ verdict_badge({"grade": r.health.overall, "verdict": r.health.verdict}) }}</a>{% endmacro %}
modified templates/pages/home.html
@@ -15,7 +15,7 @@       starter list and its deep price history. This dashboard fills in once data lands.</p>  </section>  {% else %}  {% from "includes/macros.html" import spark_card, mover_row, standing_row, picks_column %}  {% from "includes/macros.html" import spark_card, mover_row, standing_row, health_row, picks_column %}  <div class="page-head">    <h1>Markets</h1>
@@ -70,6 +70,31 @@    {% for slate in pick_slates %}{{ picks_column(slate) }}{% endfor %}  </div>  <h2 class="section-title">Stock health{% if health_asof %}<span class="section-title__asof">last sync {{ health_asof|ago }}</span>{% endif %}</h2>  <p class="section-note">Fundamentals + recent price and growth trajectory +     leadership stability, rolled into one read per stock. Industry context     is not yet folded in.</p>  <p class="disclaimer">For fun and reading at a glance. Not investment advice     and not a buy or sell signal.</p>  <div class="movers">    <section class="movers__panel">      <h3 class="movers__title">Healthiest</h3>      {% if healthiest %}      <div class="movers__list">{% for r in healthiest %}{{ health_row(r) }}{% endfor %}</div>      {% else %}      <p class="movers__empty">No graded fundamentals yet.</p>      {% endif %}    </section>    <section class="movers__panel">      <h3 class="movers__title">Most concerning</h3>      {% if concerning %}      <div class="movers__list">{% for r in concerning %}{{ health_row(r) }}{% endfor %}</div>      {% else %}      <p class="movers__empty">No graded fundamentals yet.</p>      {% endif %}    </section>  </div>  <h2 class="section-title">Strongest &amp; weakest{% if standings_asof %}<span class="section-title__asof">fundamentals synced {{ standings_asof|ago }}</span>{% endif %}</h2>  <p class="section-note">A broader read than the day&rsquo;s move: each curated     stock&rsquo;s fundamentals graded and rolled up, weighted with its price and
modified templates/pages/symbol.html
@@ -204,6 +204,49 @@  {# --- fundamentals + financials: stocks only --- #}  {% if symbol.kind == 'stock' %}  {# --- stock health read (Phase 17): a single non-advice synthesis of the         three signals below (fundamentals + price/growth trajectory +         leadership stability), so the headline reads in one glance before         the per-component sections. Hidden until ratios have synced. --- #}  {% if health %}  <h2 class="section-title">Stock health</h2>  <p class="section-note">A read across the data this app already carries:     fundamentals, recent price and growth trajectory, and leadership stability.     Industry context is not yet folded in.</p>  <p class="disclaimer">For fun and reading at a glance. Not investment advice     and not a buy or sell signal.</p>  <section class="panel health">    <div class="health__overall">      {{ verdict_badge({"grade": health.overall, "verdict": health.verdict}) }}      <p class="health__overall-text">Overall, {{ symbol.ticker }} reads as        <strong>{{ health.verdict|lower }}</strong>.</p>    </div>    <ul class="health__breakdown">      <li class="health-row health-row--{{ health.strength }}">        <span class="health-row__label">Fundamentals</span>        <span class="health-row__value">{{ health.strength_label }}</span>        <span class="health-row__note">across the nine graded ratios</span>      </li>      <li class="health-row health-row--{{ health.trajectory }}">        <span class="health-row__label">Trajectory</span>        <span class="health-row__value">{{ health.trajectory_label }}</span>        <span class="health-row__note">trailing-year price trend + revenue / earnings growth</span>      </li>      <li class="health-row health-row--{{ health.stability }}">        <span class="health-row__label">Leadership</span>        <span class="health-row__value">{{ health.stability_label }}</span>        <span class="health-row__note">          {%- if health.stability == 'unknown' -%}            leadership sync has not reached this stock yet          {%- else -%}            {{ health.recent_changes }} reported officer or director change{% if health.recent_changes != 1 %}s{% endif %} in the last 2 years          {%- endif -%}        </span>      </li>    </ul>  </section>  {% endif %}  <h2 class="section-title">Fundamentals{% if symbol.fundamentals_synced_at %}<span class="section-title__asof">synced from SEC {{ symbol.fundamentals_synced_at|ago }}</span>{% endif %}</h2>  {% if fundamentals and fundamentals.ratios %}  {# The rolled-up standing badge sits above the per-ratio cards (Phase 20). #}