@@ -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;
@@ -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(),
@@ -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 & 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 %} · <span data-field="quoted">quoted {{ symbol.last_quote_at|ago }}</span>{% endif %}{% else %}Last close · {{ 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> · <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' -%} — {%- else -%} {{ health.stability_label }} {%- endif -%} </span> </li> </ul> <p class="health-pop__src">A read across fundamentals, recent price & 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' %}↑{% elif e.glyph == 'down' %}↓{% elif e.glyph == 'drawdown' %}↡{% elif e.glyph == 'fund-up' %}+{% elif e.glyph == 'fund-down' %}−{% else %}❖{% 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 %}