repos

Stock health: header badge, anomaly grid, VIX off Indexes

cd4c3e7c by Isaac Bythewood · 1 month ago

Stock health: header badge, anomaly grid, VIX off Indexes

- Symbol page: replace the Stock health section with a circular % donut
  badge in the header top-right. Hover/focus popover carries the three
  sub-readings. HealthRead gains a percent field (linear map of the
  [-1, 1] composite to [0, 100]).
- Notable recent events: rebuild on a 4-column CSS grid (date | glyph |
  body | ext-icon) so columns align across every row; widen the date
  column to fit "Jul 25, 2025". Each row picks up a faint polarity tint
  (green for up, red for down/drawdown, neutral for leadership).
  AnomalyEvent gains a polarity field set at every creation site.
- Home: pull ^VIX out of Indexes (back to a clean 5-up row) and prepend
  it to a renamed "Risk & commodities" section so it leads.
modified PLAN.md
@@ -32,6 +32,13 @@ and resume cleanly from this file alone, keeping token use low._Last updated: 2026-05-23_**In-flight polish (not yet deployed, 2026-05-23):** stock health movedfrom its dedicated section to a circular % donut badge in the symbol-pageheader (popover carries the three sub-readings); Notable-recent-eventslist rebuilt on a 4-col grid with polarity-tinted rows; `^VIX` moved offthe Indexes row into a renamed "Risk & commodities" section so Indexesis a clean 5-up. See the Decisions log entry for the full breakdown.**Current phase: Phase 31 (full UI polish pass) complete, verified,and deployed to production 2026-05-23 (commit `d53651e`).** Driven bythe user's "really do a full pass" steer. The Paper Ledger look is
@@ -2500,6 +2507,33 @@ finance/## Decisions log- **2026-05-23 — Stock health moved to a header badge; `^VIX` moved off  the Indexes row.** Three small polish tweaks driven by user feedback  on the symbol and home pages. Not yet deployed.  - *Header health badge.* The Phase 17 "Stock health" panel was visually    heavy and lived deep below the chart. Replaced with a circular % donut    in the symbol-page header top-right; the three sub-readings    (fundamentals / trajectory / leadership) live in a hover/focus popover    on the badge, and the dedicated section is gone. `HealthRead` gained    a `percent: u8` field — a linear map of the `[-1, 1]` composite to    `[0, 100]`. The new SCSS uses the standard stroke-dasharray-on-a-    circle trick (r = 50/π so circumference = 100) so the template just    writes `stroke-dasharray="N, 100"` with no maths. The badge spans the    header's three rows at desktop and sits beside the ticker on phone.  - *Notable recent events redesign.* Anomaly rows were flex-laid-out, so    a wider date pushed the glyph and body right and made vertical    comparison hard. Rebuilt as a 4-column CSS grid (date | glyph | body |    ext-icon) with a fixed 6.25rem date column (wide enough for    "Jul 25, 2025"). Each row also picks up a faint polarity tint —    green for `up` / `fund-up`, red for `down` / `drawdown` / `fund-down`,    neutral well for `leader` — so a scan reveals whether recent events    skew positive or negative without reading any words. `AnomalyEvent`    gained a `polarity: &'static str` field set at every creation site.  - *`^VIX` off the Indexes row.* It was leaving Indexes with 6 cards (5    wide row + 1 lonely card on a second row). Removed from the `INDEXES`    list and prepended to `COMMODITIES`; the section heading is now    "Risk & commodities". Indexes is back to a clean single-row 5-up.- **2026-05-21 — Stooq history endpoint hit an apikey gate.** Stooq's free  per-ticker CSV now returns "Get your apikey:" and its bulk database download  returns "Unauthorized". Briefly considered moving history to Yahoo.
modified frontend/static_src/symbol/styles/symbol.scss
@@ -2,27 +2,39 @@@use "../../base/styles/mixins" as *;/* ---------- header ----------   At desktop the header splits into a 2-column grid: the identity   (ticker + name + tag chips) on the left, the live quote block on the   right. At phone they stack. The double-rule under the header echoes   Identity (ticker + tag chips), name, and live quote stack as three   rows. When a stock has a synthesised health read, a circular % badge   anchors the top-right and spans those three rows so id / name / quote   read as one column on the left and the badge sits alone on the right   at desktop. On phone it slides under the quote so the ticker keeps   the full width of the screen. The double-rule under the header echoes   the new .section-title ledger underline so the page reads as a single   accounting register. */.sym-head {  display: grid;  grid-template-columns: 1fr;  gap: var(--sp-3) var(--sp-6);  grid-template-columns: minmax(0, 1fr);  gap: var(--sp-3) var(--sp-5);  margin: var(--sp-1) 0 var(--sp-5);  padding-bottom: var(--sp-4);  border-bottom: 1px solid var(--rule-strong);  box-shadow: 0 3px 0 -2px var(--rule);}/* With a health badge, the header becomes a 2-column grid at every width:   left column flexes, right column is auto-sized to the badge. Stacking   on phone is handled inside .sym-head__health (it justifies left and   the badge shrinks). */.sym-head--has-health {  grid-template-columns: minmax(0, 1fr) auto;}.sym-head__id {  display: flex;  align-items: center;  gap: var(--sp-3);  flex-wrap: wrap;  min-width: 0;  grid-column: 1;}.sym-head__ticker {
@@ -55,6 +67,9 @@  }}/* Name + quote span the full header width by default; the desktop rule   below pulls them back to column 1 when the health badge is present   (the badge then anchors the right column across all three rows). */.sym-head__name {  grid-column: 1 / -1;  color: var(--ink-dim);
@@ -95,6 +110,185 @@  margin-left: 2px;}/* ---------- header health badge (Phase 17 redesign) ----------   A circular % donut anchors the header's top-right. The previous   full-width Stock health section is gone; the three sub-readings   (fundamentals / trajectory / leadership) live in a popover that   shows on hover and on keyboard / touch focus.   The donut uses the well-known stroke-dasharray trick: r = 50/π so the   circle's circumference is exactly 100, and "stroke-dasharray: N, 100"   then fills exactly N% of the ring with no maths in the template. */.sym-head__health {  grid-column: 2;  grid-row: 1;  align-self: center;  justify-self: end;  position: relative;}.health-badge {  appearance: none;  display: grid;  grid-template-columns: auto auto;  align-items: center;  gap: 0 var(--sp-3);  padding: 6px 14px 6px 6px;  background: var(--paper);  border: 1px solid var(--rule-strong);  border-radius: 999px;  color: var(--ink);  cursor: help;  font: inherit;  text-align: left;  transition: border-color 0.12s, box-shadow 0.12s, transform 0.12s;  &:hover,  &:focus-visible {    border-color: var(--ink);    box-shadow: 0 1px 0 0 var(--rule);  }}.health-badge__ring {  grid-row: 1 / span 2;  width: 52px;  height: 52px;  display: block;  /* rotate -90deg so the 0% mark sits at the top */  transform: rotate(-90deg);}.health-badge__track {  stroke: var(--rule);  stroke-width: 3.2;}.health-badge__fill {  stroke-width: 3.2;  stroke-linecap: round;  transition: stroke-dasharray 0.4s ease;}.health-badge--good .health-badge__fill { stroke: var(--up); }.health-badge--ok   .health-badge__fill { stroke: var(--warn); }.health-badge--bad  .health-badge__fill { stroke: var(--down); }.health-badge--unknown .health-badge__fill { stroke: var(--ink-faint); }.health-badge__num {  @include mono;  font-weight: 700;  font-size: var(--fs-lg);  line-height: 1;}.health-badge--good .health-badge__num { color: var(--up); }.health-badge--ok   .health-badge__num { color: var(--warn); }.health-badge--bad  .health-badge__num { color: var(--down); }.health-badge__unit {  font-size: 0.65em;  margin-left: 1px;  color: var(--ink-faint);  font-weight: 600;}.health-badge__caption {  @include eyebrow;  font-size: var(--eye-chip);  color: var(--ink-dim);  letter-spacing: 0.06em;}/* Hover / focus popover. Pure CSS — opens on the badge's :hover, on   keyboard focus (:focus-visible bubbles through :focus-within), and   stays open while the pointer is on the popover itself so a user   moving the cursor in to read it does not flicker the popover shut. */.health-pop {  position: absolute;  top: calc(100% + 8px);  right: 0;  z-index: 30;  width: min(320px, calc(100vw - 32px));  padding: var(--sp-3) var(--sp-3) var(--sp-2);  background: var(--paper);  border: 1px solid var(--rule-strong);  border-radius: var(--radius-sm);  box-shadow: 0 8px 24px -8px rgba(33, 31, 26, 0.18);  opacity: 0;  pointer-events: none;  transform: translateY(-4px);  transition: opacity 0.12s, transform 0.12s;}.sym-head__health:hover .health-pop,.sym-head__health:focus-within .health-pop {  opacity: 1;  pointer-events: auto;  transform: translateY(0);}.health-pop__head {  margin: 0 0 var(--sp-2);  padding-bottom: var(--sp-2);  border-bottom: 1px solid var(--rule);  font-size: var(--fs-sm);  color: var(--ink-dim);}.health-pop__verdict--good { color: var(--up); }.health-pop__verdict--ok   { color: var(--warn); }.health-pop__verdict--bad  { color: var(--down); }.health-pop__verdict--unknown { color: var(--ink-faint); }.health-pop__rows {  margin: 0;  padding: 0;  list-style: none;  display: grid;  gap: 4px;}.health-pop__row {  display: grid;  grid-template-columns: 1fr auto;  align-items: baseline;  gap: var(--sp-3);  padding: 6px var(--sp-2);  border-left: 3px solid var(--rule);  background: var(--well);  border-radius: var(--radius-sm);}.health-pop__row--good { border-left-color: var(--up); }.health-pop__row--ok   { border-left-color: var(--warn); }.health-pop__row--bad  { border-left-color: var(--down); }.health-pop__row--unknown { border-left-color: var(--ink-faint); }.health-pop__label {  @include eyebrow;  color: var(--ink);}.health-pop__value {  @include mono;  font-weight: 700;  font-size: var(--fs-sm);}.health-pop__row--good .health-pop__value { color: var(--up); }.health-pop__row--ok   .health-pop__value { color: var(--warn); }.health-pop__row--bad  .health-pop__value { color: var(--down); }.health-pop__row--unknown .health-pop__value { color: var(--ink-faint); }.health-pop__src {  margin: var(--sp-2) 0 0;  font-size: var(--fs-2xs);  color: var(--ink-faint);  line-height: 1.45;  font-style: italic;}/* ---------- chart panel ---------- */.chart-panel {  padding: var(--sp-3) var(--sp-3) var(--sp-2);
@@ -424,88 +618,6 @@  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: var(--sp-3) var(--sp-4) var(--sp-4);}.health__overall {  display: flex;  align-items: center;  gap: var(--sp-3);  padding-bottom: var(--sp-3);  margin-bottom: var(--sp-3);  border-bottom: 1px solid var(--rule);}/* the overall verdict reads larger than the inline app-wide pill */.health__overall .vbadge {  font-size: var(--eye-lbl);  padding: 7px 14px;}.health__overall-text {  max-width: 70ch;  color: var(--ink-dim);  font-size: var(--fs-md);}.health__breakdown {  display: grid;  gap: var(--sp-2);}/* Phase 31: the three health sub-rows now stack as fluid two-column rows:   `label | value` on a single line, with the note on a second line. That   was the phone fallback before — the desktop "label | value | note"   layout fell apart for the longer notes ("X reported officer or director   changes in the last 2 years") which wrapped into a tight third column   and clipped on the right. The two-line shape is the same at every   width; the left border and coloured value carry the semantic read. */.health-row {  display: grid;  grid-template-columns: minmax(0, 1fr) auto;  align-items: baseline;  gap: 2px var(--sp-3);  padding: var(--sp-2) var(--sp-3);  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 {  @include mono;  font-weight: 700;  font-size: var(--fs-md);}.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 {  grid-column: 1 / -1;  font-size: var(--fs-xs);  color: var(--ink-faint);  line-height: 1.45;}/* ---------- fundamentals: graded ratio cards ---------- */.fund-basis {  margin: 2px 0 14px;
@@ -940,30 +1052,57 @@  transform: translate(2px, -2px);}/* ---------- per-ticker anomaly feed (Phase 16) ---------- *//* ---------- per-ticker anomaly feed (Phase 16, redesigned) ----------   Every row uses the same 4-column grid so the date, the glyph, the   headline body, and the external-link icon land in the same x at   every row. The previous flex layout let each column flow to its   content width, which made dates of different widths (e.g. May 1 vs   May 14) push the glyph and body around — defeating the "scan   straight down" purpose of the feed.   A faint background tint on each row carries the event's polarity   (good / bad / neutral) so a quick scan reveals the recent trend   without reading any words. */.anomalies {  padding: var(--sp-3) var(--sp-4);  padding: var(--sp-2) var(--sp-3);}.anomaly-list {  margin: 0;  padding: 0;  list-style: none;  display: grid;  gap: 2px;}.anomaly {  border-top: 1px solid var(--rule);  border-radius: var(--radius-sm);  border-left: 3px solid transparent;}.anomaly:first-child {  border-top: none;.anomaly--good {  background: color-mix(in oklab, var(--up) 8%, transparent);  border-left-color: color-mix(in oklab, var(--up) 65%, transparent);}.anomaly--bad {  background: color-mix(in oklab, var(--down) 9%, transparent);  border-left-color: color-mix(in oklab, var(--down) 65%, transparent);}.anomaly--neutral {  background: var(--well);  border-left-color: var(--rule-strong);}.anomaly__link {  display: flex;  display: grid;  /* date | glyph | body | ext-icon — all fixed widths except body,     so every row's glyph and body line up exactly under each other. */  /* date col fits "Jul 25, 2025" (the shortdate filter adds the year for     prior calendar years), glyph col fixed, body flexes, ext-icon fixed. */  grid-template-columns: 6.25rem 1.5rem minmax(0, 1fr) 1.25rem;  align-items: center;  gap: var(--sp-3);  padding: var(--sp-2) 0;  padding: 9px var(--sp-3);  color: var(--ink);  text-decoration: none;  transition: color 0.1s;
@@ -975,19 +1114,17 @@ a.anomaly__link:hover .anomaly__body {.anomaly__date {  @include mono;  flex: none;  min-width: 7ch;  font-size: var(--fs-xs);  color: var(--ink-faint);  font-variant-numeric: tabular-nums;}.anomaly__glyph {  flex: none;  width: 1.4em;  text-align: center;  font-weight: 700;  font-size: var(--fs-md);  color: var(--ink-faint);  line-height: 1;}.anomaly__glyph--up        { color: var(--up); }
@@ -998,12 +1135,24 @@ a.anomaly__link:hover .anomaly__body {.anomaly__glyph--leader    { color: var(--ink-dim); }.anomaly__body {  flex: 1;  min-width: 0;  font-size: var(--fs-sm);  line-height: 1.4;}/* ext icon slot is always rendered so the body column stays the same   width whether or not a row has an outbound link. Empty span is fine. */.anomaly__ext {  display: flex;  align-items: center;  justify-content: center;  color: var(--ink-faint);}.anomaly__ext .filing__ext {  width: 14px;  height: 14px;}.anomaly-list__src {  margin: var(--sp-3) 0 0;  padding-top: var(--sp-2);
@@ -1512,6 +1661,20 @@ a.anomaly__link:hover .anomaly__body {    font-size: 2.2rem;  }  /* Desktop: the badge spans all three header rows so name + quote pull     back to column 1; the badge then sits as a clean anchor on the right     edge instead of crowding the ticker row. */  .sym-head--has-health {    .sym-head__name,    .sym-head__quote {      grid-column: 1;    }    .sym-head__health {      grid-row: 1 / span 3;      align-self: start;    }  }  .range-btn,  .ind-btn {    min-height: 34px;
modified src/compute.rs
@@ -939,6 +939,9 @@ pub struct HealthRead {    pub verdict: &'static str,    /// Composite score in [-1, 1]; home panels sort by it.    pub score: f64,    /// `score` mapped linearly to a 0-100 percentage for the header badge:    /// `-1.0` reads 0%, `0.0` reads 50%, `+1.0` reads 100%.    pub percent: u8,    pub strength: Grade,    pub strength_label: &'static str,    pub trajectory: Grade,
@@ -1033,6 +1036,9 @@ pub fn health_read(    }    let score = weighted / total;    let overall = score_grade(score);    // Linear map [-1, 1] → [0, 100] for the header badge. Clamped because a    // small floating-point drift past ±1 should not blow past 0% / 100%.    let percent = (((score + 1.0) / 2.0 * 100.0).round() as i32).clamp(0, 100) as u8;    let strength = score_grade(strength_raw);    let trajectory = trajectory_raw.map(score_grade).unwrap_or(Grade::Unknown);
@@ -1042,6 +1048,7 @@ pub fn health_read(        overall,        verdict: health_verdict(overall),        score,        percent,        strength,        strength_label: strength.verdict(),        trajectory,
@@ -1474,6 +1481,12 @@ pub struct AnomalyEvent {    /// Glyph key the template maps to an icon — one of `up`, `down`,    /// `drawdown`, `fund-up`, `fund-down`, `leader`.    pub glyph: &'static str,    /// `good` | `bad` | `neutral` — drives the row's background tint in the    /// feed so a one-glance scan reveals whether recent events skew positive    /// or negative. Up / fund-up are good, down / drawdown / fund-down are    /// bad, leadership changes are neutral (an officer change is not itself    /// good or bad news).    pub polarity: &'static str,    /// Human one-line headline, e.g. `+8.2% one-day move`.    pub headline: String,    /// Outbound link (set on leadership events; the row becomes an anchor).
@@ -1538,10 +1551,15 @@ pub fn price_anomalies(closes: &[f64], dates: &[&str]) -> Vec<AnomalyEvent> {        let sigma = var.sqrt();        if r.abs() >= PRICE_MIN_MOVE && r.abs() >= PRICE_SIGMA_MULT * sigma {            let pct = r * 100.0;            let (glyph, sign) = if pct >= 0.0 { ("up", "+") } else { ("down", "\u{2212}") };            let (glyph, polarity, sign) = if pct >= 0.0 {                ("up", "good", "+")            } else {                ("down", "bad", "\u{2212}")            };            out.push(AnomalyEvent {                date: dates[i].to_string(),                glyph,                polarity,                headline: format!("{sign}{:.1}% one-day move", pct.abs()),                url: None,                severity: pct.abs(),
@@ -1587,6 +1605,7 @@ pub fn drawdown_anomalies(closes: &[f64], dates: &[&str]) -> Vec<AnomalyEvent> {            out.push(AnomalyEvent {                date: dates[i].to_string(),                glyph: "drawdown",                polarity: "bad",                headline: format!("New 6-month low ({:.0}% off peak)", drop),                url: None,                severity: drop.abs(),
modified src/models.rs
@@ -186,14 +186,15 @@ pub fn fundamentals_anomalies(facts: &[FundFact]) -> Vec<compute::AnomalyEvent>            "net_income" => "net income",            _ => metric,        };        let (glyph, sign) = if pct >= 0.0 {            ("fund-up", "+")        let (glyph, polarity, sign) = if pct >= 0.0 {            ("fund-up", "good", "+")        } else {            ("fund-down", "\u{2212}")            ("fund-down", "bad", "\u{2212}")        };        out.push(compute::AnomalyEvent {            date: period_end.to_string(),            glyph,            polarity,            headline: format!("FY{year} {label} {sign}{:.0}% YoY", pct.abs()),            url: None,            severity: pct.abs(),
modified src/routes/home.rs
@@ -27,22 +27,26 @@ pub fn router() -> Router<AppState> {/// The dashboard's index cards: each cash index paired with its index future./// Outside the regular cash session the future is shown in the card's place/// (it trades nearly around the clock, while the cash index sits frozen on its/// last close; see PLAN.md Phase 21). The Nasdaq Composite (`^NDQ`) and the/// volatility index (`^VIX`) have no clean tradable future, so they always/// show the cash index. Hardcoded on purpose: the home page is a fixed,/// opinionated view, not a user-built watchlist./// last close; see PLAN.md Phase 21). The Nasdaq Composite (`^NDQ`) has no/// clean tradable future, so it always shows the cash index. Hardcoded on/// purpose: the home page is a fixed, opinionated view, not a user-built/// watchlist. Five cards = one clean desktop row.const INDEXES: &[(&str, Option<&str>)] = &[    ("^SPX", Some("ES=F")),    ("^DJI", Some("YM=F")),    ("^NDX", Some("NQ=F")),    ("^RUT", Some("RTY=F")),    ("^NDQ", None),    ("^VIX", None),];/// The dashboard's commodity cards: WTI crude, gold, natural gas. Shown as the/// futures themselves, since there is no cash instrument to swap to.const COMMODITIES: &[&str] = &["CL=F", "GC=F", "NG=F"];/// The dashboard's risk + commodity cards: the volatility gauge first, then/// WTI crude, gold, and natural gas. Shown as the futures themselves for the/// three commodities (no cash instrument to swap to) and as the cash index/// for `^VIX` (no tradable future for it on Yahoo). VIX leads here rather/// than living in the Indexes row because it is a derived sentiment gauge,/// not a price index — putting it first turns the section into a quick/// "what is the market worried about today" panel.const COMMODITIES: &[&str] = &["^VIX", "CL=F", "GC=F", "NG=F"];/// How many gainers and how many losers each movers panel lists.const MOVERS_LIMIT: usize = 8;
modified src/routes/symbols.rs
@@ -1120,6 +1120,7 @@ async fn build_anomalies(            events.push(AnomalyRow {                date: filed_at,                glyph: "leader",                polarity: "neutral",                headline: "Officer or director change reported in an 8-K".to_string(),                url: Some(url),                // Hand-picked: above a typical 5-8% one-day move so a leadership
modified templates/pages/home.html
@@ -31,7 +31,7 @@    {% for c in index_cards %}{{ spark_card(c) }}{% endfor %}  </div>  <h2 class="section-title">Commodities{% if commodity_asof %}<span class="section-title__asof">prices as of <span data-field="spark-asof">{{ commodity_asof|asof }}</span></span>{% endif %}</h2>  <h2 class="section-title">Risk &amp; commodities{% if commodity_asof %}<span class="section-title__asof">prices as of <span data-field="spark-asof">{{ commodity_asof|asof }}</span></span>{% endif %}</h2>  <div class="spark-grid">    {% for c in commodity_cards %}{{ spark_card(c) }}{% endfor %}  </div>
modified templates/pages/symbol.html
@@ -33,7 +33,7 @@{% block main %}<div class="wrap">  <header class="sym-head">  <header class="sym-head{% if health %} sym-head--has-health{% endif %}">    <div class="sym-head__id">      <h1 class="sym-head__ticker">{{ symbol.ticker }}</h1>      <span class="sym-head__tag">{{ symbol.kind }}</span>
@@ -58,6 +58,55 @@      <span class="sym-head__asof">{% if quote %}{{ quote.state_label }}{% if symbol.last_quote_at %} &middot; <span data-field="quoted">quoted {{ symbol.last_quote_at|ago }}</span>{% endif %}{% else %}Last close &middot; {{ stats.date|shortdate }}{% endif %}</span>    </div>    {% endif %}    {# Stock health badge (Phase 17, redesigned): a circular % donut anchors       the top-right of the header. Hovering or focusing reveals the three       sub-readings (fundamentals / trajectory / leadership) — the previous       full Stock-health section is gone in favour of this glanceable badge. #}    {% if health %}    <div class="sym-head__health">      <button type="button" class="health-badge health-badge--{{ health.overall }}"              aria-label="Stock health: {{ health.verdict }}, {{ health.percent }}%"              aria-describedby="health-pop">        {# r = 50/π ≈ 15.9155 makes the circle's circumference exactly 100,           so stroke-dasharray "N, 100" fills N% of the ring with no maths           in the template. #}        <svg class="health-badge__ring" viewBox="0 0 36 36" aria-hidden="true">          <circle class="health-badge__track" cx="18" cy="18" r="15.9155" fill="none"/>          <circle class="health-badge__fill"  cx="18" cy="18" r="15.9155" fill="none"                  stroke-dasharray="{{ health.percent }}, 100"/>        </svg>        <span class="health-badge__num num">          <span class="health-badge__pct">{{ health.percent }}</span><span class="health-badge__unit">%</span>        </span>        <span class="health-badge__caption">{{ health.verdict }}</span>      </button>      <div class="health-pop" id="health-pop" role="tooltip">        <p class="health-pop__head">Overall <strong class="health-pop__verdict health-pop__verdict--{{ health.overall }}">{{ health.verdict }}</strong> &middot; <span class="num">{{ health.percent }}%</span></p>        <ul class="health-pop__rows">          <li class="health-pop__row health-pop__row--{{ health.strength }}">            <span class="health-pop__label">Fundamentals</span>            <span class="health-pop__value">{{ health.strength_label }}</span>          </li>          <li class="health-pop__row health-pop__row--{{ health.trajectory }}">            <span class="health-pop__label">Trajectory</span>            <span class="health-pop__value">{{ health.trajectory_label }}</span>          </li>          <li class="health-pop__row health-pop__row--{{ health.stability }}">            <span class="health-pop__label">Leadership</span>            <span class="health-pop__value">              {%- if health.stability == 'unknown' -%}                &mdash;              {%- else -%}                {{ health.stability_label }}              {%- endif -%}            </span>          </li>        </ul>        <p class="health-pop__src">A read across fundamentals, recent price &amp; growth trajectory, and leadership stability. For fun and reading at a glance, not investment advice.</p>      </div>    </div>    {% endif %}  </header>  {# Chart and key stats need daily history. A future has none by design
@@ -258,49 +307,6 @@  </section>  {% endif %}  {# --- stock health read (Phase 17): a single non-advice synthesis of the         three signals below (fundamentals + price/growth trajectory +         leadership stability), so the headline reads in one glance before         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). #}
@@ -398,16 +404,18 @@  <section class="panel anomalies">    <ul class="anomaly-list">      {% for e in anomalies.events %}      <li class="anomaly anomaly--{{ e.glyph }}">      <li class="anomaly anomaly--{{ e.glyph }} anomaly--{{ e.polarity }}">        {% if e.url %}<a class="anomaly__link" href="{{ e.url }}" target="_blank" rel="noopener noreferrer">{% else %}<span class="anomaly__link">{% endif %}          <span class="anomaly__date num">{{ e.date|shortdate }}</span>          <span class="anomaly__glyph anomaly__glyph--{{ e.glyph }}" aria-hidden="true">{% if e.glyph == 'up' %}&uarr;{% elif e.glyph == 'down' %}&darr;{% elif e.glyph == 'drawdown' %}&#x21A1;{% elif e.glyph == 'fund-up' %}&plus;{% elif e.glyph == 'fund-down' %}&minus;{% else %}&#x2756;{% endif %}</span>          <span class="anomaly__body">{{ e.headline }}</span>          {% if e.url %}          <svg class="filing__ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">            <path d="M14 5h5v5M19 5l-9 9M11 5H6a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5"/>          </svg>          {% endif %}          <span class="anomaly__ext" aria-hidden="true">            {% if e.url %}            <svg class="filing__ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">              <path d="M14 5h5v5M19 5l-9 9M11 5H6a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5"/>            </svg>            {% endif %}          </span>        {% if e.url %}</a>{% else %}</span>{% endif %}      </li>      {% endfor %}