repos

Add strongest & weakest home panels and app-wide standing badge (Phase 20)

0abb30cf by Isaac Bythewood · 1 month ago

Add strongest & weakest home panels and app-wide standing badge (Phase 20)

Roll the nine graded fundamental ratios into a single strong/fair/weak
verdict, shown as a badge on the symbol page (above the ratio cards),
search result cards, and mover rows.

Add a "Strongest & weakest" pair of home panels that rank the curated
large-cap stocks by a combined score over fundamental strength and
trajectory (fundamentals weighted ~2:1; trajectory blends a 12-month
price trend with the revenue/earnings growth grades). One per-render
scan of the curated stocks feeds both the movers and the new panels.

All derived from existing Phase 7 fundamentals and daily_prices: no new
data source, no new network calls.
modified PLAN.md
@@ -33,15 +33,18 @@ and resume cleanly from this file alone, keeping token use low._Last updated: 2026-05-22_**Current phase:** Phases 0 through 12 (the MVP) plus Phase 18 (ETF profiles)are complete, verified, and **live in production athttps://finance.bythewood.me** (Phase 18 deployed 2026-05-22). Remainingpost-MVP backlog: phases 13 through 17 and 19.and Phase 20 (strongest & weakest home panels) are complete, verified, and**live in production at https://finance.bythewood.me** (Phase 20 deployed2026-05-22). No phase is in progress. Remaining post-MVP work: theloose-ordered Phase 13-17 and 19 backlog.**Roadmap (restructured 2026-05-22, see decisions log):** the home-pageredesign and commodities are pre-ship MVP phases. Order: 9 Search +add-symbol, 10 Commodities & futures, 11 Home dashboard redesign, 12 Polish +ship. Post-MVP backlog is phases 13 through 19; the user picked Phase 18 (ETFprofiles) as the first post-MVP phase, done 2026-05-22.profiles) as the first post-MVP phase, done 2026-05-22. Phase 20 (strongest &weakest home panels) was then inserted 2026-05-22 as a detour, to be builtnext, ahead of the loose-ordered 13-17 backlog.**Watchlists dropped from the MVP (2026-05-22, see decisions log):** the userno longer wants watchlists for now and wants the app to stay an opinionated,
@@ -520,15 +523,54 @@ schema, unused for now.    the Paper Ledger look at 1280px and 390px with no overflow and zero    console errors; `/s/AAPL` (stock) and `/s/^VIX` (index) are unchanged.- **Phase 20 strongest & weakest home panels.** Complete, verified, deployed  to production.  - `compute.rs` gained a Phase 20 section: a `Standing` (a strong / fair /    weak `Grade` plus a combined score) and the pure functions behind it.    `grade_value` / `graded_mean` roll the nine Phase 7 ratio grades into a    fundamental-strength score; `price_trend_score` reads a trailing-year    return blended with how steady the climb was (the share of ~monthly    sub-blocks that did not fall); `trajectory_score` blends that price trend    equally with the revenue- and earnings-growth ratio grades; `standing`    combines strength and trajectory ~2:1 in favour of fundamentals (a user    steer); `trailing_return` exposes the 12-month return for display.  - The badge's verdict reflects fundamental strength alone (it sits over the    ratio cards); the combined score, which also folds in trajectory, is what    the home panels rank by.  - `models.rs` now owns `FundFact` (moved out of `routes/symbols.rs`) and a    shared `latest_annual_inputs` that assembles `RatioInputs` for the latest    fiscal year, so the symbol page and the home ranking grade a stock    identically. `Card` gained an optional `strength`.  - `routes/home.rs`: one `load_stocks` scan of the curated `is_seeded`    stocks (price + all fundamentals + the trailing-year daily closes, three    queries) feeds both the movers and the new strongest / weakest panels.    `movers` was refactored to reuse that scan and now carries each row's    badge; `strength_panels` ranks the graded stocks by combined score and    takes the top 8 and bottom 8, with a magnitude tint scaled like the    movers tint.  - `routes/search.rs`: `attach_standings` batch-loads fundamentals for the    stock rows among the results and attaches each card's badge.  - A shared `verdict_badge` macro and a `.vbadge` style (a semantic    green/amber/red pill, in `base.scss`); the badge rides on ticker cards,    mover rows, the new standing rows, and above the symbol page's ratio    cards. A new `standing_row` macro and a "Strongest & weakest" home    section mirror the movers layout.  - Verified: cargo + bun build clean; `/` renders the two new panels with    badges on the movers; `/s/NVDA` shows a "Strong" overall badge above the    ratios and `/s/AAPL` a "Fair" one; `/search` badges the stock cards and    leaves ETFs / indexes / futures unbadged; desktop (1280px) and phone    (390px) render with no overflow and zero console errors. `/` renders in    ~225ms warm — the per-render standings scan, a fixed page-load snapshot    as planned.**Resuming, next action****The MVP plus Phase 18 (ETF profiles) are live athttps://finance.bythewood.me.** No phase is in progress. Phase 18 was deployedon 2026-05-22 via `git push server master`; migration `0005` applies on thebox and the `sec` job backfills the 28 ETF profiles on its first due cycle.Remaining future work is the post-MVP backlog, phases 13 through 17 and 19below; ordering among them is loose. There is still no GitHub repo forfinance: the user deferred that; if one is created later, add it as `origin`and the `overshard/finance` slug already in taproot's `projects.conf` lines up.**The MVP plus Phase 18 (ETF profiles) and Phase 20 (strongest & weakest homepanels) are live at https://finance.bythewood.me.** No phase is in progress.Phase 20 was deployed on 2026-05-22 via `git push server master`. Theremaining work is the loose-ordered Phase 13-17 and 19 backlog; the user pickswhich to take next. There is still no GitHub repo for finance: the userdeferred that; if one is created later, add it as `origin` and the`overshard/finance` slug already in taproot's `projects.conf` lines up.Note: Phase 18 added the `quick-xml` crate (N-PORT XML streaming parser) andmigration `0005`. A fresh `make run` applies `0005`; the ETF fund profiles
@@ -784,7 +826,9 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data).  "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 Phases 7, 14 and 15.  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 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
@@ -801,6 +845,32 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data).  be an opinionated, no-customization market view; parked here and can be  re-promoted later if that changes (as commodities once were).- [x] **Phase 20: Strongest & weakest (home page).** Complete, verified, and  deployed to production (2026-05-22) — see the Phase 20 entry in Status and  the decisions log. (Captured 2026-05-22 as a detour ahead of the 13-17  backlog.) A second pair of home panels alongside the day's movers, but  a fundamentals-and-trajectory lens rather than a one-day price move: the  strongest stocks and the weakest, a broader read on what is built well and  what is struggling ("very similar to top movers, just a broader view").  Planned pieces: (1) a composite fundamental-strength grade in `compute.rs`  that rolls the nine Phase 7 graded ratios into a single strong / fair / weak  verdict per stock; (2) a trajectory measure blending recent price trend  (trailing return and how consistent the climb has been, from `daily_prices`)  with fundamental growth (the Phase 7 revenue-growth and earnings-growth  grades); (3) a combined per-stock score over strength plus trajectory, the  home page showing the top N strongest and bottom N weakest by it, mirroring  the movers panels (curated `is_seeded` stocks only, soft magnitude tint, a  fixed page-load snapshot). Fundamentals exist only for stocks, so the ranking  is necessarily stocks-only. (4) The composite strong / fair / weak verdict is  surfaced consistently across the app: an overall badge on the symbol page  above the per-ratio cards, plus a badge on search result rows and mover rows.  All of it is derived from data already stored (Phase 7 fundamentals plus  `daily_prices`): no new data source, no new network calls, no new endpoint  guard. This phase is the foundation for Phase 17: it ships the  fundamentals-plus-trajectory half of the eventual "health read", and Phase 17  later layers leadership (Phase 14) and industry context (Phase 15) on top.  Built in the Paper Ledger system.---## Key files
@@ -1189,6 +1259,49 @@ finance/  SEC-backed data backfills within a tick instead of waiting out the ~24h  interval. That is what populates the Phase 18 ETF profiles on the production  box right after a deploy; the job is cheap when nothing is stale.- **2026-05-22: Phase 20 inserted — strongest & weakest home panels.** The  user wants the home page to carry, alongside the day's movers, a broader  fundamentals-and-trajectory read: the strongest stocks (best combination of  fundamental strength and price / business trajectory) and the weakest, "very  similar to top movers, just a broader view". They also want the strong /  fair / weak verdict shown consistently across the app, not just per-ratio on  the symbol page. Budgeted as a new Phase 20, inserted as the next phase to  build, ahead of the loose-ordered 13-17 backlog. Q&A settled four points:  (1) it ships next, before 13-17; (2) it is the foundation for Phase 17, not  a replacement: Phase 20 builds the composite fundamental-strength grade, the  trajectory measure and the home panels, and Phase 17 later layers leadership  (Phase 14) and industry context (Phase 15) on top into the fuller "health  read"; (3) "trajectory" blends both recent price trend (from `daily_prices`)  and fundamental growth (the Phase 7 revenue / earnings growth grades); (4)  the rolled-up strong / fair / weak badge appears everywhere: symbol pages,  search result rows, mover rows, and the new panels. It is numbered after the  existing backlog rather than renumbering 13-19 (backlog ordering is already  loose, as Phase 18 going first showed); its Phases-list entry is flagged as  the next to build. No new data source: it is derived wholly from Phase 7  fundamentals and `daily_prices`.- **2026-05-22: Phase 20 strongest & weakest shipped.** The home page gained a  "Strongest & weakest" pair of panels beside the movers, and a rolled-up  strong / fair / weak badge now rides across the app. Design calls made  during the build: (1) the badge's verdict reflects fundamental strength  alone — the mean of the nine Phase 7 ratio grades — because it sits directly  above the ratio cards on the symbol page and must be explainable by what is  on screen; the home panels instead rank by a *combined* score that also  folds in trajectory. (2) Per the user's steer the combined score weights  fundamentals ~2:1 over trajectory (`STRENGTH_WEIGHT = 2/3`). (3) Trajectory  blends a price-trend score with the revenue/earnings-growth grades; the  price trend is a trailing-year (12 months, the user's pick) return blended  with a steadiness measure — the share of ~monthly sub-blocks that closed up.  The two growth grades thus feed both halves (strength and trajectory); the  overlap is deliberate, the plan specified it. (4) Verdict cutoffs are a  narrow ±0.2 band on the [-1, 1] score, since curated large-caps cluster near  zero; they are tunable consts in `compute.rs`. (5) `FundFact` and the  latest-fiscal-year `RatioInputs` assembly moved into `models.rs` so the  symbol page and the home ranking grade a stock identically. (6) The home  route computes the standings per render — one scan of the curated stocks  (price + all fundamentals + a trailing year of daily closes) feeding both  the movers and the new panels, a fixed page-load snapshot as planned; `/`  renders in ~225ms warm. No new data source and no new network calls.  Deployed to production on 2026-05-22 via `git push server master`.---
@@ -1210,4 +1323,7 @@ finance/  (`/s/GLD`) shows AUM and filings only, with no holdings.- Phone (~360 px) and desktop are both fully usable: no unintended horizontal  scroll, chart resizes, every feature reachable.- `/` carries a "Strongest & weakest" pair of panels below the movers, and a  strong / fair / weak standing badge rides on the movers, the search result  cards, and above the symbol page's ratio cards (Phase 20).- No automated test suite or linter, matching the sibling projects.
modified frontend/static_src/base/styles/base.scss
@@ -509,7 +509,7 @@ main {.ticker-card__head {  display: flex;  align-items: baseline;  align-items: center;  justify-content: space-between;  gap: 8px;}
@@ -520,6 +520,13 @@ main {  font-size: 1rem;}/* the standing badge and the kind eyebrow, grouped at the head's right edge */.ticker-card__tags {  display: flex;  align-items: center;  gap: 6px;}.ticker-card__kind {  @include eyebrow;  font-size: 0.55rem;
@@ -580,6 +587,35 @@ main {  }}/* ---------- verdict badge (Phase 20) ---------- *//* The rolled-up strong / fair / weak company standing, shown as one pill   across the app: on a ticker card, a mover row, a strongest / weakest row,   and above the symbol page's ratio cards. Carries the semantic colour, like   the per-ratio .ratio__badge it sits alongside. */.vbadge {  @include eyebrow;  flex: none;  padding: 3px 7px;  border-radius: 999px;  font-size: 0.52rem;  white-space: nowrap;}.vbadge--good {  color: var(--up);  background: var(--up-soft);}.vbadge--ok {  color: var(--warn);  background: var(--warn-soft);}.vbadge--bad {  color: var(--down);  background: var(--down-soft);}/* ---------- desktop layering ---------- */@media (min-width: $bp-md) {  .search {
modified frontend/static_src/home/styles/home.scss
@@ -26,6 +26,14 @@  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;
@@ -185,7 +193,8 @@.mover {  position: relative;  display: grid;  grid-template-columns: auto minmax(0, 1fr) auto auto;  /* sym, name, standing badge, price, day change */  grid-template-columns: auto minmax(0, 1fr) auto auto auto;  align-items: center;  gap: 12px;  padding: 10px 6px;
@@ -251,9 +260,97 @@  min-width: 4.4ch;}/* the badge cell: always present (empty for a not-yet-graded stock) so the   row's grid keeps a fixed column count */.mover__badge {  display: flex;}/* ---------- strongest / weakest panels (Phase 20) ---------- *//* Rows reuse the .movers panel chrome but rank on the rolled-up standing   rather than the day's price move. Each carries a soft magnitude tint sized   by --bar, mirroring .mover. */.standing {  position: relative;  display: grid;  /* sym, name, trailing-year return, verdict badge */  grid-template-columns: auto minmax(0, 1fr) auto auto;  align-items: center;  gap: 12px;  padding: 10px 6px;  color: var(--ink);  border-bottom: 1px solid var(--rule);}.standing:last-child {  border-bottom: 0;}/* the magnitude tint behind the row; a pseudo-element keeps it independent of   the hover background */.standing::before {  content: "";  position: absolute;  left: 0;  top: 0;  bottom: 0;  width: var(--bar, 0%);  border-radius: var(--radius-sm);}.standing--up::before {  background: var(--up-soft);}.standing--down::before {  background: var(--down-soft);}.standing > * {  position: relative; /* sit above the magnitude tint */}.standing:hover {  color: var(--ink);  background: var(--well);}.standing__sym {  font-weight: 700;  font-size: 0.86rem;}.standing__name {  font-size: 0.78rem;  color: var(--ink-dim);  white-space: nowrap;  overflow: hidden;  text-overflow: ellipsis;}/* the trailing-year return, a quiet context figure beside the verdict */.standing__ret {  font-size: 0.8rem;  color: var(--ink-dim);  text-align: right;  min-width: 5ch;}/* ---------- desktop layering ---------- */@media (min-width: $bp-md) {  .movers {    grid-template-columns: 1fr 1fr;  }}/* on a narrow phone the mover price yields to the badge and day change, the   row's key figures (mirrors the .hold row's phone treatment) */@media (max-width: $bp-sm) {  .mover {    grid-template-columns: auto minmax(0, 1fr) auto auto;  }  .mover__price {    display: none;  }}
modified frontend/static_src/symbol/styles/symbol.scss
@@ -387,6 +387,27 @@  font-size: 0.82rem;}/* the rolled-up strong / fair / weak standing badge with a one-line reading,   sitting above the per-ratio grid (Phase 20) */.fund-standing {  display: flex;  align-items: flex-start;  gap: 12px;  margin: 2px 0 16px;}/* the badge reads larger here than the inline app-wide pill */.fund-standing .vbadge {  font-size: 0.72rem;  padding: 6px 13px;}.fund-standing__text {  max-width: 70ch;  color: var(--ink-dim);  font-size: 0.86rem;}.ratios {  display: grid;  grid-template-columns: repeat(auto-fit, minmax(258px, 1fr));
modified src/compute.rs
@@ -546,3 +546,170 @@ fn earnings_growth(net_income: Option<f64>, prev_net_income: Option<f64>) -> Rat    };    mk(KEY, LABEL, EXPLAIN, format!("{v:+.1}%"), grade, reading)}// ─────────────────────── company standing (Phase 20) ───────────────────────//// A stock's overall standing rolls its nine graded ratios into a single// strong / fair / weak verdict — the badge shown across the app — and combines// that fundamental strength with a price-and-growth trajectory into one score// the home page ranks the strongest and weakest stocks by. Everything here is// pure: it derives only from the Phase 7 ratios and a daily-close series, with// no new data source./// Of the nine ratios, how many must carry a real grade (not `Unknown`) before/// a strength verdict is meaningful. A company reporting almost nothing gets no/// badge rather than one resting on one or two figures.const MIN_GRADED: usize = 5;/// Strength-score cutoffs for the strong / fair / weak verdict. The score is a/// mean of per-ratio values in [-1, 1]; a curated large-cap typically lands/// near zero, so the band is deliberately narrow. Tunable.const STRONG_CUTOFF: f64 = 0.2;const WEAK_CUTOFF: f64 = -0.2;/// Weight of fundamental strength in the combined score; trajectory takes the/// rest. ~2:1 in favour of fundamentals (a user steer — the ranking should/// lean on how well a company is built over how its price has lately moved).const STRENGTH_WEIGHT: f64 = 2.0 / 3.0;/// Trading days in the trailing-year price-trend window (~12 months).const TREND_WINDOW: usize = 252;/// Trading days per sub-block when measuring how steady the climb was (~1mo).const TREND_BLOCK: usize = 21;/// Minimum history (~3 months) before a price trend is read at all.const TREND_MIN: usize = TREND_BLOCK * 3;/// A trailing return of this magnitude (a fraction, so 0.25 = ±25%) saturates/// the return component of the price-trend score.const TREND_SATURATION: f64 = 0.25;/// A stock's rolled-up standing: the strong / fair / weak verdict shown as a/// badge across the app, plus a combined strength-and-trajectory score the/// home "Strongest & weakest" panels rank by.#[derive(Debug, Clone, Copy, Serialize)]pub struct Standing {    /// CSS hook for the badge: `good` | `ok` | `bad`. Mirrors `Grade`, so it    /// reuses the per-ratio badge colours.    pub grade: Grade,    /// Badge text derived from `grade`: `Strong` | `Fair` | `Weak`.    pub verdict: &'static str,    /// Combined score in [-1, 1]; the home panels sort by it. The verdict    /// above reflects fundamental strength alone (it sits over the ratio    /// cards); this score additionally folds in trajectory.    pub score: f64,}/// A grade's numeric value for averaging: `Good` +1, `Ok` 0, `Bad` −1./// `Unknown` carries no value and is skipped by the mean.fn grade_value(g: Grade) -> Option<f64> {    match g {        Grade::Good => Some(1.0),        Grade::Ok => Some(0.0),        Grade::Bad => Some(-1.0),        Grade::Unknown => None,    }}/// Mean of the graded values in `grades`, ignoring `Unknown`. `None` when/// fewer than `min` of them carried a grade.fn graded_mean(grades: impl Iterator<Item = Grade>, min: usize) -> Option<f64> {    let vals: Vec<f64> = grades.filter_map(grade_value).collect();    (vals.len() >= min).then(|| vals.iter().sum::<f64>() / vals.len() as f64)}/// Map a score in [-1, 1] to a strong / fair / weak `Grade`.fn score_grade(score: f64) -> Grade {    if score >= STRONG_CUTOFF {        Grade::Good    } else if score <= WEAK_CUTOFF {        Grade::Bad    } else {        Grade::Ok    }}/// Trailing return (percent) over the price-trend window, for display beside a/// stock's standing. `None` with less than a few months of history.pub fn trailing_return(closes: &[f64]) -> Option<f64> {    if closes.len() < TREND_MIN {        return None;    }    let window = &closes[closes.len().saturating_sub(TREND_WINDOW)..];    let (&first, &last) = (window.first()?, window.last()?);    (first > 0.0).then(|| (last - first) / first * 100.0)}/// Score the trailing-year price trend in [-1, 1]: a trailing return blended/// with how steady the climb was — the share of ~monthly sub-blocks that did/// not fall. `None` with too little history to judge.fn price_trend_score(closes: &[f64]) -> Option<f64> {    if closes.len() < TREND_MIN {        return None;    }    let window = &closes[closes.len().saturating_sub(TREND_WINDOW)..];    let (&first, &last) = (window.first()?, window.last()?);    if first <= 0.0 {        return None;    }    // Return component: a move of ±TREND_SATURATION over the window saturates.    let ret = (last - first) / first;    let ret_comp = (ret / TREND_SATURATION).clamp(-1.0, 1.0);    // Steadiness: the fraction of ~monthly blocks that closed up, recentred to    // [-1, 1] so an all-up year reads +1 and an all-down year −1.    let (mut blocks, mut up) = (0u32, 0u32);    let mut i = 0;    while i + TREND_BLOCK < window.len() {        blocks += 1;        if window[i + TREND_BLOCK] >= window[i] {            up += 1;        }        i += TREND_BLOCK;    }    let steady_comp = if blocks > 0 {        (f64::from(up) / f64::from(blocks) - 0.5) * 2.0    } else {        ret_comp    };    // The return carries most of the weight; steadiness only refines it.    Some(0.7 * ret_comp + 0.3 * steady_comp)}/// Trajectory score in [-1, 1]: the recent price trend blended equally with/// fundamental growth (the revenue- and earnings-growth ratio grades). `None`/// when neither half can be computed.fn trajectory_score(ratios: &[Ratio], closes: &[f64]) -> Option<f64> {    let price = price_trend_score(closes);    let growth = graded_mean(        ratios            .iter()            .filter(|r| matches!(r.key, "revenue_growth" | "earnings_growth"))            .map(|r| r.grade),        1,    );    match (price, growth) {        (Some(p), Some(g)) => Some((p + g) / 2.0),        (Some(v), None) | (None, Some(v)) => Some(v),        (None, None) => None,    }}/// Roll a stock's nine graded ratios and its price trajectory into a single/// [`Standing`]. `ratios` is the output of [`compute_ratios`]; `closes` is a/// daily-close series (oldest first) over roughly the trailing year, which may/// be empty. `None` when too few ratios graded to judge.pub fn standing(ratios: &[Ratio], closes: &[f64]) -> Option<Standing> {    // Fundamental strength: the mean grade across all nine ratios. The badge's    // verdict reflects this alone, since it sits over the ratio cards.    let strength = graded_mean(ratios.iter().map(|r| r.grade), MIN_GRADED)?;    let grade = score_grade(strength);    // Combined score: fundamentals weighted ~2:1 over trajectory. With no    // trajectory to read, strength stands alone.    let score = match trajectory_score(ratios, closes) {        Some(t) => STRENGTH_WEIGHT * strength + (1.0 - STRENGTH_WEIGHT) * t,        None => strength,    };    Some(Standing {        grade,        verdict: grade.verdict(),        score,    })}
modified src/models.rs
@@ -1,5 +1,7 @@//! Shared `sqlx` row structs and small cross-route view models.use std::collections::HashMap;use serde::Serialize;use sqlx::FromRow;
@@ -48,10 +50,14 @@ pub struct Card {    pub price: Option<f64>,    pub change_abs: Option<f64>,    pub change_pct: Option<f64>,    /// The rolled-up strong / fair / weak verdict badge (Phase 20). Stocks    /// only, and only once SEC fundamentals have synced; `None` otherwise.    pub strength: Option<compute::Standing>,}/// Build a [`Card`] from a selected price row, computing the change off the/// price and its prior close./// price and its prior close. The `strength` badge is left unset; a caller/// with fundamentals on hand fills it in.pub fn to_card((ticker, name, kind, last, prev): SymbolCardRow) -> Card {    let (change_abs, change_pct) = match (last, prev) {        (Some(l), Some(p)) => {
@@ -67,5 +73,49 @@ pub fn to_card((ticker, name, kind, last, prev): SymbolCardRow) -> Card {        price: last,        change_abs,        change_pct,        strength: None,    }}/// One fundamentals fact as stored: a metric's value for one fiscal period.#[derive(Debug, Clone, FromRow)]pub struct FundFact {    pub metric: String,    pub period: String,    pub fiscal_year: i64,    pub fiscal_qtr: Option<i64>,    pub value: f64,}/// Assemble [`compute::RatioInputs`] for a company's most recent full fiscal/// year from its stored facts plus a price. Annual rows only; the prior year's/// figures (for the growth ratios) come from `latest_fy - 1`. `None` when the/// company has no annual facts. Shared by the symbol page and the home/// strongest / weakest ranking so both grade a stock identically.pub fn latest_annual_inputs(facts: &[FundFact], price: Option<f64>) -> Option<compute::RatioInputs> {    // (metric, fiscal_year) -> value, annual rows only.    let mut annual: HashMap<(&str, i64), f64> = HashMap::new();    let mut latest_fy: Option<i64> = None;    for f in facts {        if f.fiscal_qtr.is_none() {            annual.insert((f.metric.as_str(), f.fiscal_year), f.value);            latest_fy = Some(latest_fy.map_or(f.fiscal_year, |y| y.max(f.fiscal_year)));        }    }    let fy = latest_fy?;    let av = |m: &str, y: i64| annual.get(&(m, y)).copied();    Some(compute::RatioInputs {        price,        eps_diluted: av("eps_diluted", fy),        dividends_per_share: av("dividends_per_share", fy),        revenue: av("revenue", fy),        net_income: av("net_income", fy),        assets: av("assets", fy),        liabilities: av("liabilities", fy),        equity: av("equity", fy),        assets_current: av("assets_current", fy),        liabilities_current: av("liabilities_current", fy),        prev_revenue: av("revenue", fy - 1),        prev_net_income: av("net_income", fy - 1),    })}
modified src/routes/home.rs
@@ -1,10 +1,11 @@//! `GET /` — the markets dashboard.//!//! An opinionated, no-customization read of the market: a row of sparkline//! cards for the major US indexes and the headline commodities, and the day's//! biggest movers among the curated large-cap universe. There is deliberately//! no per-user layout — the app decides what matters (see PLAN.md Phase 11).//! The full, browsable universe lives on `/search`.//! cards for the major US indexes and the headline commodities, the day's//! biggest movers, and a strongest / weakest read over the curated large-cap//! stocks. There is deliberately no per-user layout — the app decides what//! matters (see PLAN.md Phases 11 and 20). The full, browsable universe lives//! on `/search`.use std::cmp::Ordering;use std::collections::HashMap;
@@ -13,7 +14,7 @@ use axum::{extract::State, response::Response, routing::get, Router};use serde::Serialize;use crate::compute::{self, Sparkline};use crate::models::SymbolCardRow;use crate::models::{self, SymbolCardRow};use crate::render::render;use crate::AppState;
@@ -32,12 +33,20 @@ const DASHBOARD: &[&str] = &[/// How many gainers and how many losers each movers panel lists.const MOVERS_LIMIT: usize = 8;/// How many stocks each of the strongest / weakest panels lists. Mirrors/// `MOVERS_LIMIT` so the two pairs of panels read alike.const STANDING_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/// just the latest day.const SESSION_WINDOW_MS: i64 = 23 * 3600 * 1000;/// Calendar days of daily closes to pull for the trajectory read. Comfortably/// over the ~252 trading days `compute`'s trend window needs.const TREND_LOOKBACK_DAYS: i64 = 400;/// One sparkline card on the dashboard's top row.#[derive(Serialize)]struct SparkCard {
@@ -62,6 +71,38 @@ struct Mover {    /// Width (0..100) of the row's magnitude tint, scaled to the largest    /// absolute move shown across both panels.    bar: f64,    /// The stock's rolled-up strong / fair / weak badge (Phase 20); `None`    /// until its SEC fundamentals have synced.    strength: Option<compute::Standing>,}/// One row in a strongest / weakest panel.#[derive(Serialize, Clone)]struct StandingRow {    ticker: String,    name: String,    /// The combined fundamentals-and-trajectory standing this row is ranked by.    standing: compute::Standing,    /// Trailing 12-month return, percent; `None` when history is too short.    ret_12m: Option<f64>,    /// Width (0..100) of the row's magnitude tint, scaled to the largest    /// absolute score shown across both panels.    bar: f64,    /// Colour hook: true when the combined 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.struct StockRow {    ticker: String,    name: String,    last: Option<f64>,    prev: Option<f64>,    standing: Option<compute::Standing>,    ret_12m: Option<f64>,}async fn home(State(state): State<AppState>) -> Response {
@@ -83,7 +124,11 @@ async fn home(State(state): State<AppState>) -> Response {        .unwrap_or(seeded);    let spark_cards = dashboard_cards(&state).await;    let (gainers, losers) = movers(&state).await;    // One scan of the curated stocks feeds both the movers and the strongest /    // weakest panels.    let stocks = load_stocks(&state).await;    let (gainers, losers) = movers(&stocks);    let (strongest, weakest) = strength_panels(&stocks);    let extra = minijinja::context! {        title => "Markets",
@@ -91,6 +136,8 @@ async fn home(State(state): State<AppState>) -> Response {        spark_cards => spark_cards,        gainers => gainers,        losers => losers,        strongest => strongest,        weakest => weakest,        total => total,    };    render(&state, "pages/home.html", "/", extra)
@@ -156,13 +203,17 @@ async fn dashboard_cards(state: &AppState) -> Vec<SparkCard> {    cards}/// The day's biggest gainers and losers among the curated large-cap stocks./// Every curated large-cap stock, each graded into a [`StockRow`].////// Restricted to `is_seeded` stocks on purpose: the movers are meant to be/// names worth noticing (an AAPL down 5%), not a small user-added symbol's/// noise. ETFs, indexes, and futures are excluded — only single stocks.async fn movers(state: &AppState) -> (Vec<Mover>, Vec<Mover>) {    let rows: Vec<(String, String, Option<f64>, Option<f64>)> = sqlx::query_as(/// Restricted to `is_seeded` stocks on purpose (see PLAN.md Phase 11): the/// home panels are meant to show names worth noticing, not a small user-added/// symbol's noise. Three queries — price, fundamentals, trailing-year closes —/// then each stock is graded in `compute`. ETFs, indexes and futures are/// 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>)> = 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)), \
@@ -173,23 +224,90 @@ async fn movers(state: &AppState) -> (Vec<Mover>, Vec<Mover>) {    .fetch_all(&state.pool)    .await    .unwrap_or_default();    if price_rows.is_empty() {        return Vec::new();    }    // Keep only stocks with a computable change.    let mut all: Vec<Mover> = rows    // 2. Every stored fundamentals fact for the curated stocks, grouped by    //    ticker — the basis for each stock's graded ratios.    let fact_rows: Vec<(String, String, String, i64, Option<i64>, f64)> = sqlx::query_as(        "SELECT f.ticker, f.metric, f.period, f.fiscal_year, f.fiscal_qtr, f.value \         FROM fundamentals f JOIN symbols s ON s.ticker = f.ticker \         WHERE s.is_seeded = 1 AND s.kind = 'stock'",    )    .fetch_all(&state.pool)    .await    .unwrap_or_default();    let mut facts: HashMap<String, Vec<models::FundFact>> = HashMap::new();    for (ticker, metric, period, fiscal_year, fiscal_qtr, value) in fact_rows {        facts.entry(ticker).or_default().push(models::FundFact {            metric,            period,            fiscal_year,            fiscal_qtr,            value,        });    }    // 3. The trailing-year daily closes per curated stock, oldest first.    let cutoff = (chrono::Utc::now().date_naive() - chrono::Duration::days(TREND_LOOKBACK_DAYS))        .to_string();    let close_rows: Vec<(String, f64)> = sqlx::query_as(        "SELECT p.ticker, p.close FROM daily_prices p JOIN symbols s ON s.ticker = p.ticker \         WHERE s.is_seeded = 1 AND s.kind = 'stock' AND p.d >= ? ORDER BY p.ticker, p.d",    )    .bind(&cutoff)    .fetch_all(&state.pool)    .await    .unwrap_or_default();    let mut closes: HashMap<String, Vec<f64>> = HashMap::new();    for (ticker, close) in close_rows {        closes.entry(ticker).or_default().push(close);    }    // 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.    price_rows        .into_iter()        .filter_map(|(ticker, name, last, prev)| {            let (last, prev) = (last?, prev?);        .map(|(ticker, name, last, prev)| {            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 {                ticker,                name,                last,                prev,                standing,                ret_12m: compute::trailing_return(stock_closes),            }        })        .collect()}/// The day's biggest gainers and losers among the curated large-cap stocks./// Each row also carries the stock's strong / fair / weak badge (Phase 20).fn movers(stocks: &[StockRow]) -> (Vec<Mover>, Vec<Mover>) {    // Keep only stocks with a computable change.    let mut all: Vec<Mover> = stocks        .iter()        .filter_map(|s| {            let (last, prev) = (s.last?, s.prev?);            if prev == 0.0 {                return None;            }            let c = compute::change(last, prev);            Some(Mover {                ticker,                name,                ticker: s.ticker.clone(),                name: s.name.clone(),                price: last,                change_abs: c.abs,                change_pct: c.pct,                bar: 0.0,                strength: s.standing,            })        })        .collect();
@@ -223,3 +341,57 @@ async fn movers(state: &AppState) -> (Vec<Mover>, Vec<Mover>) {    }    (gainers, losers)}/// The strongest and weakest curated stocks by their combined Phase 20 score.////// A fundamentals-and-trajectory lens on the same curated large-caps the/// movers panels draw from — a broader read than the day's price move. A stock/// with no graded standing (its SEC fundamentals have not synced) is left out.fn strength_panels(stocks: &[StockRow]) -> (Vec<StandingRow>, Vec<StandingRow>) {    // Rank only the stocks that earned a standing, best combined score first.    let mut ranked: Vec<&StockRow> = stocks.iter().filter(|s| s.standing.is_some()).collect();    if ranked.is_empty() {        return (Vec::new(), Vec::new());    }    ranked.sort_by(|a, b| {        let (sa, sb) = (a.standing.unwrap().score, b.standing.unwrap().score);        sb.partial_cmp(&sa).unwrap_or(Ordering::Equal)    });    let row = |s: &StockRow| {        let standing = s.standing.unwrap();        StandingRow {            ticker: s.ticker.clone(),            name: s.name.clone(),            standing,            ret_12m: s.ret_12m,            bar: 0.0,            up: standing.score >= 0.0,        }    };    let mut strongest: Vec<StandingRow> =        ranked.iter().copied().take(STANDING_LIMIT).map(&row).collect();    let mut weakest: Vec<StandingRow> = ranked        .iter()        .copied()        .rev()        .take(STANDING_LIMIT)        .map(&row)        .collect();    // Scale every magnitude tint to the largest absolute score shown, so the    // two panels read against one another (mirrors the movers tint).    let max_abs = strongest        .iter()        .chain(weakest.iter())        .map(|r| r.standing.score.abs())        .fold(0.0_f64, f64::max);    for r in strongest.iter_mut().chain(weakest.iter_mut()) {        r.bar = if max_abs > 0.0 {            (r.standing.score.abs() / max_abs * 100.0).clamp(0.0, 100.0)        } else {            0.0        };    }    (strongest, weakest)}
modified src/routes/search.rs
@@ -6,6 +6,8 @@//! hold yet, the page offers to add it; the Search page's script does that//! through `POST /api/symbols` (see `routes::symbols`).use std::collections::HashMap;use axum::{    extract::{Query, State},    response::Response,
@@ -14,7 +16,8 @@ use axum::{};use serde::Deserialize;use crate::models::{to_card, Card, SymbolCardRow};use crate::compute;use crate::models::{self, to_card, Card, SymbolCardRow};use crate::render::render;use crate::routes::symbols::valid_ticker;use crate::AppState;
@@ -87,9 +90,14 @@ async fn search_page(Query(sq): Query<SearchQuery>, State(state): State<AppState    .await    .unwrap_or_default();    let results: Vec<Card> = rows.into_iter().map(to_card).collect();    let mut results: Vec<Card> = rows.into_iter().map(to_card).collect();    let result_count = results.len() as i64;    // Attach each stock card's strong / fair / weak verdict badge (Phase 20).    // ETFs, indexes and futures carry no badge — only stocks have the SEC    // fundamentals a standing is rolled from.    attach_standings(&state, &mut results).await;    // Offer "Add" only on a genuine miss: nothing matched, the query is a    // plausible ticker (one `POST /api/symbols` would accept), and it is not    // already a symbol the kind filter happened to hide. Requiring zero
@@ -113,3 +121,49 @@ async fn search_page(Query(sq): Query<SearchQuery>, State(state): State<AppState    };    render(&state, "pages/search.html", "/search", extra)}/// Fill in the `strength` badge for the stock cards in `cards`, in one batch/// query over their stored SEC fundamentals. Non-stock cards are left/// untouched. The badge reflects fundamental strength only — the home page is/// where the trajectory half is read — so no daily-close series is needed.async fn attach_standings(state: &AppState, cards: &mut [Card]) {    let stock_tickers: Vec<&str> = cards        .iter()        .filter(|c| c.kind == "stock")        .map(|c| c.ticker.as_str())        .collect();    if stock_tickers.is_empty() {        return;    }    // The `IN` list is built from tickers already in `symbols`, not raw user    // input, so the placeholder count is bounded and safe.    let placeholders = vec!["?"; stock_tickers.len()].join(",");    let sql = format!(        "SELECT ticker, metric, period, fiscal_year, fiscal_qtr, value \         FROM fundamentals WHERE ticker IN ({placeholders})"    );    let mut q = sqlx::query_as::<_, (String, String, String, i64, Option<i64>, f64)>(&sql);    for t in &stock_tickers {        q = q.bind(*t);    }    let fact_rows = q.fetch_all(&state.pool).await.unwrap_or_default();    let mut facts: HashMap<String, Vec<models::FundFact>> = HashMap::new();    for (ticker, metric, period, fiscal_year, fiscal_qtr, value) in fact_rows {        facts.entry(ticker).or_default().push(models::FundFact {            metric,            period,            fiscal_year,            fiscal_qtr,            value,        });    }    for card in cards.iter_mut().filter(|c| c.kind == "stock") {        card.strength = facts.get(&card.ticker).and_then(|f| {            let inputs = models::latest_annual_inputs(f, card.price)?;            compute::standing(&compute::compute_ratios(&inputs), &[])        });    }}
modified src/routes/symbols.rs
@@ -14,7 +14,7 @@ use crate::compute;use crate::db::now_ms;use crate::guard::{EndpointGuard, Permit};use crate::market;use crate::models::SymbolRow;use crate::models::{self, SymbolRow};use crate::providers::http;use crate::providers::yahoo::{SymbolLookup, YahooProvider};use crate::render::{not_found, render};
@@ -95,16 +95,6 @@ fn quote_state_label() -> &'static str {/// Placeholder for a fundamentals cell the company did not report.const DASH: &str = "\u{00b7}";/// One fundamentals fact, as stored.#[derive(sqlx::FromRow)]struct FundFact {    metric: String,    period: String,    fiscal_year: i64,    fiscal_qtr: Option<i64>,    value: f64,}/// One row of a financials table: a metric label and its formatted value in/// each displayed period (`·` where the company reported nothing).#[derive(Serialize)]
@@ -271,20 +261,15 @@ fn fund_table(/// Assemble the fundamentals view from a company's stored facts plus the/// latest price. `None` when the company has no fundamentals stored yet.fn build_fundamentals(facts: &[FundFact], price: Option<f64>) -> Option<FundamentalsView> {fn build_fundamentals(facts: &[models::FundFact], price: Option<f64>) -> Option<FundamentalsView> {    if facts.is_empty() {        return None;    }    // (metric, period) -> value, for table-cell lookup.    let mut lookup: HashMap<(String, String), f64> = HashMap::new();    // (metric, fiscal_year) -> value, for the annual ratio inputs.    let mut annual_vals: HashMap<(&str, i64), f64> = HashMap::new();    for f in facts {        lookup.insert((f.metric.clone(), f.period.clone()), f.value);        if f.fiscal_qtr.is_none() {            annual_vals.insert((f.metric.as_str(), f.fiscal_year), f.value);        }    }    // Distinct annual periods, oldest first, most recent 5 kept.
@@ -312,29 +297,13 @@ fn build_fundamentals(facts: &[FundFact], price: Option<f64>) -> Option<Fundamen        .map(|(y, _, p)| (y, p))        .collect();    // Ratios run off the most recent full fiscal year.    // Ratios run off the most recent full fiscal year; the shared helper in    // `models` assembles the inputs so the home ranking grades stocks the    // same way this page does.    let latest_fy = annual.last().map(|(y, _)| *y);    let ratios = match latest_fy {        Some(fy) => {            let av = |m: &str, y: i64| annual_vals.get(&(m, y)).copied();            let inputs = compute::RatioInputs {                price,                eps_diluted: av("eps_diluted", fy),                dividends_per_share: av("dividends_per_share", fy),                revenue: av("revenue", fy),                net_income: av("net_income", fy),                assets: av("assets", fy),                liabilities: av("liabilities", fy),                equity: av("equity", fy),                assets_current: av("assets_current", fy),                liabilities_current: av("liabilities_current", fy),                prev_revenue: av("revenue", fy - 1),                prev_net_income: av("net_income", fy - 1),            };            compute::compute_ratios(&inputs)        }        None => Vec::new(),    };    let ratios = models::latest_annual_inputs(facts, price)        .map(|inputs| compute::compute_ratios(&inputs))        .unwrap_or_default();    Some(FundamentalsView {        basis: latest_fy.map(|y| format!("FY{y}")),
@@ -540,7 +509,7 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        .or_else(|| stats.as_ref().map(|s| s.close));    let fundamentals = if is_stock {        let facts: Vec<FundFact> = sqlx::query_as(        let facts: Vec<models::FundFact> = sqlx::query_as(            "SELECT metric, period, fiscal_year, fiscal_qtr, value \             FROM fundamentals WHERE ticker = ?",        )
@@ -553,6 +522,15 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        None    };    // The overall strong / fair / weak standing (Phase 20): the ratios above    // 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 filings: Vec<FilingView> = if is_stock || is_etf {        sqlx::query_as::<_, FilingRow>(            "SELECT form, filed_at, period_of_report, url FROM filings \
@@ -610,6 +588,7 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        stats => stats,        quote => quote,        fundamentals => fundamentals,        standing => standing,        fund => fund,        filings => filings,    };
modified templates/includes/macros.html
@@ -1,10 +1,19 @@{# Reusable fragments. `ticker_card` renders one symbol tile; its data-ticker   and data-field hooks let the live stream client patch it in place. #}{# `verdict_badge` renders the rolled-up strong / fair / weak standing badge   (Phase 20). `s` is a compute::Standing: `s.grade` is the colour hook   (good/ok/bad), `s.verdict` the word (Strong/Fair/Weak). #}{% macro verdict_badge(s) %}<span class="vbadge vbadge--{{ s.grade }}">{{ s.verdict }}</span>{% endmacro %}{% macro ticker_card(c) %}<a class="ticker-card" href="/s/{{ c.ticker|urlencode }}" data-ticker="{{ c.ticker }}">  <div class="ticker-card__head">    <span class="ticker-card__sym">{{ c.ticker }}</span>    <span class="ticker-card__kind">{{ c.kind }}</span>    <span class="ticker-card__tags">      {% if c.strength %}{{ verdict_badge(c.strength) }}{% endif %}      <span class="ticker-card__kind">{{ c.kind }}</span>    </span>  </div>  <div class="ticker-card__name">{{ c.name }}</div>  <div class="ticker-card__foot">
@@ -44,13 +53,30 @@{% endmacro %}{# `mover_row` renders one gainer/loser row. The `--bar` custom property sizes   a soft magnitude tint behind the row, scaled by the route. #}   a soft magnitude tint behind the row, scaled by the route. The badge cell   is always present (empty for a stock without a synced standing) so the   row's grid keeps a fixed column count. #}{% macro mover_row(m) %}<a class="mover {{ 'mover--up' if m.change_pct >= 0 else 'mover--down' }}"   href="/s/{{ m.ticker|urlencode }}" style="--bar: {{ m.bar }}%">  <span class="mover__sym num">{{ m.ticker }}</span>  <span class="mover__name">{{ m.name }}</span>  <span class="mover__badge">{% if m.strength %}{{ verdict_badge(m.strength) }}{% endif %}</span>  <span class="mover__price num">{{ m.price|money }}</span>  <span class="mover__chg num {{ 'is-up' if m.change_pct >= 0 else 'is-down' }}">{{ m.change_pct|pct }}</span></a>{% endmacro %}{# `standing_row` renders one strongest/weakest row (Phase 20): the rolled-up   verdict badge over a combined fundamentals-and-trajectory score, with the   trailing 12-month return alongside for context. `--bar` sizes the magnitude   tint, scaled by the route to the largest score shown. #}{% macro standing_row(r) %}<a class="standing {{ 'standing--up' if r.up else 'standing--down' }}"   href="/s/{{ r.ticker|urlencode }}" style="--bar: {{ r.bar }}%">  <span class="standing__sym num">{{ r.ticker }}</span>  <span class="standing__name">{{ r.name }}</span>  <span class="standing__ret num">{% if r.ret_12m is not none %}{{ r.ret_12m|pct }}{% endif %}</span>  {{ verdict_badge(r.standing) }}</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 %}  {% from "includes/macros.html" import spark_card, mover_row, standing_row %}  <div class="page-head">    <h1>Markets</h1>
@@ -46,6 +46,29 @@      {% endif %}    </section>  </div>  <h2 class="section-title">Strongest &amp; weakest</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     growth trajectory.</p>  <div class="movers">    <section class="movers__panel">      <h3 class="movers__title">Strongest</h3>      {% if strongest %}      <div class="movers__list">{% for r in strongest %}{{ standing_row(r) }}{% endfor %}</div>      {% else %}      <p class="movers__empty">No graded fundamentals yet.</p>      {% endif %}    </section>    <section class="movers__panel">      <h3 class="movers__title">Weakest</h3>      {% if weakest %}      <div class="movers__list">{% for r in weakest %}{{ standing_row(r) }}{% endfor %}</div>      {% else %}      <p class="movers__empty">No graded fundamentals yet.</p>      {% endif %}    </section>  </div>  {% endif %}</div>{% endblock %}
modified templates/pages/symbol.html
@@ -3,6 +3,8 @@{% block description %}{{ symbol.name }} ({{ symbol.ticker }}): price chart, key stats, and history.{% endblock %}{% block extra_css %}<link rel="stylesheet" href="{{ vite_asset('static_src/symbol/index.js', 'css') }}">{% endblock %}{% from "includes/macros.html" import verdict_badge %}{# One financials table (annual or quarterly). Both are rendered; the toggle   script shows one. `active` decides which is visible on load. #}{% macro fin_panel(table, period, active) %}
@@ -202,7 +204,15 @@  <h2 class="section-title">Fundamentals</h2>  {% if fundamentals and fundamentals.ratios %}  {% if fundamentals.basis %}  {# The rolled-up standing badge sits above the per-ratio cards (Phase 20). #}  {% if standing %}  <div class="fund-standing">    {{ verdict_badge(standing) }}    <p class="fund-standing__text">Across the nine graded ratios{% if fundamentals.basis %}      ({{ fundamentals.basis }} figures against the latest price){% endif %},      {{ symbol.ticker }}&rsquo;s fundamentals read as <strong>{{ standing.verdict|lower }}</strong> overall.</p>  </div>  {% elif fundamentals.basis %}  <p class="fund-basis">Each ratio reads {{ fundamentals.basis }} figures against the latest price, graded so the quality shows at a glance.</p>  {% endif %}  <section class="ratios">