repos

Phase 5: dashboard redesign — hero verdict, breadth, ETF band

656e21d9 by Isaac Bythewood · 1 month ago

Phase 5: dashboard redesign — hero verdict, breadth, ETF band

Restructure the home dashboard into a scannable 'how is the market doing
TODAY' read with clearly labeled, dual-first bands:

- Hero: a two-line plain verdict blending the broad index move, breadth,
  and the VIX risk tone, over a compact index-chip strip with the headline
  figures and a non-advice note. Direction tracks the broad index sign so
  it never contradicts the figure shown beside it.
- Breadth band: advancers/decliners + % S&P green over an up/flat/down
  proportion bar, reusing the curated-stock scan (no extra query).
- ETF band (the Phase-4 deferral): five curated quality cards
  (VOO/VTI/QQQ/BND/GLD) over a gainers/losers strip, each carrying the
  Phase-4 ETF quality verdict.

Band order: Hero, Indexes, Breadth, ETFs, Movers, Industries, Risk &
commodities, Quality leaderboard.
modified PLAN.md
@@ -34,7 +34,7 @@ commit + auto-deploy (`git push server master`) and a clean breakpoint.## Status_Last updated: 2026-05-30 (Phase 4 done on dev — commit + deploy pending)__Last updated: 2026-05-30 (Phase 5 done on dev — commit + deploy pending)_**Major refactor in progress (the "distill + ETF-first" rewrite).** This planwas fully rewritten 2026-05-30 from a sprawling 3,700-line resume doc into this
@@ -54,11 +54,49 @@ focused roadmap. The decisions driving it are in the Decisions log under- **Everything gets distilled** into a fast-scannable, dual-first (mobile +  desktop) design while keeping the futuristic-clean "Paper Ledger" look.**Current work:** Phases 1–3 are **done and deployed.** Phase 4 (ETFs asfirst-class citizens) is **done on dev, pending commit + deploy** — qualityscore + distinct ETF symbol-page identity shipped; the dashboard ETF band wasdeferred to Phase 5 (user's call). Next after deploy: **Phase 5 (dashboardredesign)**.**Current work:** Phases 1–4 are **done, committed, and deployed** (Phase 4 islive at `19d0b14`). **Phase 5 (dashboard redesign) is done on dev, pending commit+ deploy.** Next after deploy: **Phase 6 (symbol-page distillation + liveintraday on chart).**Phase 5 outcome (dashboard redesign → "how is the market doing TODAY"):- **Hero verdict.** A two-line plain read at the top blending the broad index  move, breadth, and the VIX risk tone into a lead + clause (e.g. "Higher, but  narrow." / "Markets higher with narrow participation."), over a compact  index-chip strip, with the headline figures (S&P %, % green, VIX tone) and a  non-advice note. `build_hero` + `market_verdict` + `vix_tone` in  `src/routes/home.rs`, fed by the already-loaded index/commodity cards + breadth  (zero extra queries). **The verdict's direction tracks the broad index's sign**  so it never contradicts the "S&P +x%" shown beside it; breadth only sets  direction when no index price exists. A near-flat index reads "mixed" even when  breadth skews. (Found + fixed during review: a +0.12% S&P with weak breadth had  read "Broadly lower" — a direct contradiction; the deadband was tightened from  ±0.15% to ±0.05% and the breadth fallback narrowed.)- **Breadth band.** Advancers / decliners counts + % green over a single  up/flat/down proportion bar. `breadth()` reuses the `load_stocks` scan (no  extra query); a stock without a computable change is excluded so a missing  quote never reads as "flat".- **ETF band (the Phase-4 deferral, now built).** Five curated quality cards  (`VOO`, `VTI`, `QQQ`, `BND`, `GLD`) — intraday sparkline + day move + the  Phase-4 quality verdict pill — over a compact gainers/losers strip across the  whole curated ETF set, each pill dotted by quality grade. `load_etfs` rolls  every seeded ETF (price + Yahoo metadata + SEC AUM + top-10 concentration) into  the `etf_quality` read, reusing the same NAV-freshness gate as the symbol page.- **Band order:** Hero · Indexes · Breadth · ETFs · Stock movers · Industries ·  Risk & commodities · Quality leaderboard. The existing Industries (sector  up/down) panel was **kept as its own band** (user's call), not folded into  breadth.- **Verified on dev:** `cargo build` + `vite build` clean; home renders all bands  at desktop (1280) and mobile (390) with no console errors; hero reads  consistently with the figures beside it; breadth counts reconcile  (197 adv / 306 dec = 39% green); ETF cards show quality pills and the movers  strip ranks correctly (screenshots reviewed, then deleted). Because today is a  weekend the index cards correctly resolve to futures (ES=F, …) and breadth  dates to the prior close.- **Known limitation (Phase 7 polish candidate):** the hero verdict and breadth  are page-load snapshots — the sparkline cards still stream live, but the  verdict sentence + breadth counts do not recompute intraday. Live breadth would  need a server-pushed breadth event on the stream hub.Phase 3 outcome (drop short-horizon prediction → quality leaderboard):- **The whole picker is gone.** Removed the four horizon rankers
@@ -262,7 +300,7 @@ Kill the rate-limit problem at the root.- Note: the leaderboard's home-page placement is intentionally provisional —  Phase 5 rebuilds the dashboard (hero + bands) around it.### Phase 4 — ETFs as true first-class citizens  ✅ DONE on dev (commit + deploy pending)### Phase 4 — ETFs as true first-class citizens  ✅ DONE (deployed 2026-05-30, `19d0b14`)- ✅ Blended ETF **quality score** (`compute::etf_quality`): cost-weighted blend  of cost (40%) / tracking (25%) / diversification (20%) / size (15%), composite  −1..1 → percent, four sub-reading chips — structurally mirrors the stock
@@ -291,11 +329,17 @@ Kill the rate-limit problem at the root.  the fetch reuses the proven `quoteSummary` NAV parse, and prod exercises it on  first deploy once its guard is healthy.### Phase 5 — Dashboard redesign: "how is the market doing TODAY"- Hero: one-line plain-language market verdict + index strip + **breadth**  (advancers/decliners, % of S&P green, sector leaders/laggards).- Clearly labeled bands: Indexes · Breadth · ETFs · Stock movers · Commodities ·  Quality leaderboard. Dual-first density.### Phase 5 — Dashboard redesign: "how is the market doing TODAY"  ✅ DONE on dev (commit + deploy pending)- ✅ Hero: two-line plain-language verdict (blended index move + breadth + VIX  tone) + compact index strip + headline figures + non-advice note. Direction  tracks the broad index sign so it never contradicts the figure shown.- ✅ Breadth band: advancers/decliners + % S&P green + a proportion bar. (Sector  leaders/laggards stay in their **own** Industries band, per the user's call —  not folded into breadth.)- ✅ ETF band (the Phase-4 deferral): 5 curated quality cards + a gainers/losers  strip, each carrying the Phase-4 quality verdict.- ✅ Clearly labeled bands, dual-first density: Hero · Indexes · Breadth · ETFs ·  Stock movers · Industries · Risk & commodities · Quality leaderboard.### Phase 6 — Symbol-page distillation + live intraday on chart- Mobile above-the-fold order: price/change → health verdict → mini chart →
@@ -362,6 +406,30 @@ Kill the rate-limit problem at the root.## Decisions log**2026-05-30 — Phase 5 (dashboard redesign).** Answered 4 design forks beforebuilding:1. **Hero verdict = blended + a touch more.** A two-line read: a punchy lead   ("Higher, but narrow.") plus a clause ("Markets higher with narrow   participation."), blending the broad index move, breadth (% green), and the   VIX risk tone, with the headline figures and a non-advice note beneath.2. **Sectors stay separate.** Breadth band = advancers/decliners + % green only;   the existing Industries (sector up/down) panel keeps its own band rather than   folding "sector leaders/laggards" into breadth.3. **ETF band = both.** Curated quality cards (VOO/VTI/QQQ/BND/GLD) *and* a   compact ETF gainers/losers strip — this is the Phase-4 deferral, now built.4. **Breadth viz = stat strip + proportion bar** (advancers/decliners counts, %   green, one up/flat/down bar), not a distribution histogram.Implemented entirely in `src/routes/home.rs` + `home.html`/`macros.html` +`home.scss`, reusing the already-loaded card/stock scans so the hero and breadthadd **zero** extra queries (only the ETF band adds two: the ETF roll-up and atop-10-holdings window query). Found + fixed during the screenshot review: theverdict could contradict the figure beside it — a +0.12% S&P with weak breadthread "Broadly lower" because the ±0.15% direction deadband swallowed the move andlet breadth flip the sign. Fixed by tightening the deadband to ±0.05% and makingdirection track the broad index's sign, with breadth breaking ties only when noindex price exists. Deferred live updates for the hero/breadth (page-loadsnapshots) to the Phase 7 polish pass.**2026-05-30 — Phase 4 build + the NAV-staleness discovery.** Built the ETFquality read (`compute::etf_quality`, mirroring `health_read`) and thesymbol-page quality donut. **Mid-build discovery:** the tracking factor
modified frontend/static_src/home/styles/home.scss
@@ -26,6 +26,280 @@  text-underline-offset: 2px;}/* ============================================================================   Hero (Phase 5)   The dashboard's "how is the market doing TODAY" read: a one-line verdict   blending the broad move, breadth, and the VIX tone, over a compact index   strip. A page-load snapshot, so it reads as a plain description, not a feed.   ========================================================================== */.hero {  @include card;  display: flex;  flex-direction: column;  gap: var(--sp-4);  margin-top: var(--sp-4);  padding: var(--sp-4) var(--sp-4) var(--sp-5);}.hero__verdict {  @include serif;  font-size: var(--fs-2xl);  line-height: 1.15;  color: var(--ink);}.hero__detail {  margin-top: var(--sp-1);  font-size: var(--fs-md);  color: var(--ink-dim);}/* the headline figures behind the verdict — a quiet middot-separated row */.hero__stats {  display: flex;  flex-wrap: wrap;  gap: 4px var(--sp-3);  margin-top: var(--sp-3);  font-size: var(--fs-sm);  color: var(--ink-dim);}.hero__stat {  @include mono;  display: inline-flex;  align-items: baseline;  gap: 5px;  white-space: nowrap;}/* a thin middot between stats, drawn so it does not need markup */.hero__stat + .hero__stat::before {  content: "\00b7"; /* · */  margin-right: var(--sp-3);  color: var(--ink-faint);}.hero__note {  margin-top: var(--sp-3);  font-size: var(--fs-xs);  font-style: italic;  color: var(--ink-faint);}/* the compact index strip: clickable chips with ticker + day move */.hero__chips {  display: grid;  grid-template-columns: repeat(2, minmax(0, 1fr));  gap: var(--sp-2);}.hero__chip {  display: flex;  align-items: baseline;  justify-content: space-between;  gap: var(--sp-2);  padding: 7px 10px;  border-radius: var(--radius-sm);  background: var(--well);  color: var(--ink);  transition: background 0.14s, transform 0.14s;}.hero__chip:hover {  color: var(--ink);  transform: translateY(-1px);}.hero__chip-sym {  @include mono;  font-weight: 700;  font-size: var(--fs-sm);}.hero__chip-chg {  font-size: var(--fs-sm);  font-weight: 700;}.hero__chip.is-up .hero__chip-chg {  color: var(--up);}.hero__chip.is-down .hero__chip-chg {  color: var(--down);}@media (min-width: $bp-md) {  .hero {    flex-direction: row;    align-items: center;    justify-content: space-between;    gap: var(--sp-6);  }  .hero__read {    flex: 1 1 auto;    min-width: 0;  }  /* the strip sits to the right; five chips in a single column read as a     compact ledger of the indexes beside the verdict */  .hero__chips {    flex: 0 0 auto;    grid-template-columns: 1fr;    min-width: 168px;  }}/* ============================================================================   Breadth band (Phase 5)   Advancer / decliner counts and the share green, over a single proportion bar   of the up / flat / down split.   ========================================================================== */.breadth {  @include card;  display: flex;  flex-direction: column;  gap: var(--sp-3);  padding: var(--sp-4);}.breadth__stats {  display: flex;  flex-wrap: wrap;  align-items: baseline;  gap: 6px var(--sp-4);  font-size: var(--fs-sm);  color: var(--ink-dim);}.breadth__num {  font-weight: 700;  font-size: var(--fs-lg);}.breadth__stat--up .breadth__num {  color: var(--up);}.breadth__stat--down .breadth__num {  color: var(--down);}/* the share green sits at the right end as the headline figure */.breadth__pct {  margin-left: auto;  font-weight: 700;  font-size: var(--fs-md);  color: var(--ink);}/* the proportion bar: three segments in a sunken well, no gaps */.breadth__bar {  display: flex;  height: 12px;  border-radius: 999px;  overflow: hidden;  background: var(--well);}.breadth__seg {  height: 100%;}.breadth__seg--up   { background: var(--up); }.breadth__seg--flat { background: var(--ink-faint); }.breadth__seg--down { background: var(--down); }/* ============================================================================   ETF band quality pieces (Phase 5)   ========================================================================== *//* the quality verdict pill in an ETF card head — pushed to the right end */.qchip {  margin-left: auto;  flex: none;  align-self: center;  padding: 2px 7px;  border-radius: 999px;  font-size: 0.56rem;  font-weight: 600;  text-transform: uppercase;  letter-spacing: 0.1em;}.qchip--good     { color: var(--up);        background: var(--up-soft); }.qchip--ok       { color: var(--warn);      background: var(--warn-soft); }.qchip--bad      { color: var(--down);      background: var(--down-soft); }.qchip--unknown  { color: var(--ink-faint); background: var(--well); }/* the compact gainers / losers strip below the cards */.etf-movers {  display: grid;  grid-template-columns: 1fr;  gap: var(--sp-2);  margin-top: var(--sp-3);}.etf-movers__group {  display: flex;  flex-wrap: wrap;  align-items: center;  gap: var(--sp-2);}.etf-movers__label {  @include eyebrow;  font-size: var(--fs-2xs);  color: var(--ink-faint);  min-width: 4.5ch;}.etf-pill {  display: inline-flex;  align-items: center;  gap: 6px;  padding: 4px 9px;  border-radius: 999px;  background: var(--well);  color: var(--ink);  font-size: var(--fs-xs);  transition: transform 0.14s;}.etf-pill:hover {  color: var(--ink);  transform: translateY(-1px);}.etf-pill__dot {  width: 6px;  height: 6px;  border-radius: 999px;  flex: none;}.etf-pill__dot--good     { background: var(--up); }.etf-pill__dot--ok       { background: var(--warn); }.etf-pill__dot--bad      { background: var(--down); }.etf-pill__dot--unknown  { background: var(--ink-faint); }.etf-pill__sym {  @include mono;  font-weight: 700;}.etf-pill__chg {  font-weight: 700;}.etf-pill.is-up .etf-pill__chg   { color: var(--up); }.etf-pill.is-down .etf-pill__chg { color: var(--down); }@media (min-width: $bp-md) {  .etf-movers {    grid-template-columns: 1fr 1fr;    gap: var(--sp-4);  }}/* ---------- sparkline cards ---------- */.spark-grid {  display: grid;
modified src/routes/home.rs
@@ -47,6 +47,22 @@ const INDEXES: &[(&str, Option<&str>)] = &[/// "what is the market worried about today" panel.const COMMODITIES: &[&str] = &["^VIX", "CL=F", "GC=F", "NG=F"];/// The dashboard's curated ETF cards (Phase 5): one broad-equity anchor/// (`VOO`), a total-market and a growth/tech tilt (`VTI`, `QQQ`), a bond core/// (`BND`), and a commodity (`GLD`) — five cards spanning the asset classes a/// watcher scans first, mirroring the hardcoded `INDEXES` row. Each shows an/// intraday sparkline plus its Phase-4 quality verdict. The full ETF universe/// (and the movers strip below) lives on `/search?kind=etf`.const ETF_CARDS: &[&str] = &["VOO", "VTI", "QQQ", "BND", "GLD"];/// How many ETF gainers / losers the dashboard's compact movers strip lists.const ETF_MOVERS_LIMIT: usize = 5;/// NAV freshness gate for the ETF quality read's tracking factor (Phase 4):/// NAV is struck daily, so a price-vs-NAV premium read against a NAV older than/// this is meaningless and the factor drops out. Mirrors the symbol page.const NAV_FRESH_MS: i64 = 3 * 24 * 3600 * 1000;/// How many gainers and how many losers each movers panel lists.const MOVERS_LIMIT: usize = 8;
@@ -88,6 +104,85 @@ struct SparkSection {    asof: Option<i64>,}/// The dashboard hero (Phase 5): a one-line plain-language read of the day/// blending the broad index move, market breadth, and the VIX risk tone, with/// the headline figures behind it and a compact index strip. The verdict is a/// descriptive read of today's tape, not a forecast — the non-advice note rides/// with it.#[derive(Serialize)]struct Hero {    /// The punchy lead, e.g. "Risk-on, and broad."    verdict: String,    /// The supporting clause, e.g. "Markets higher with wide participation."    detail: String,    /// Compact index chips beside the verdict (the resolved index cards' moves).    chips: Vec<HeroChip>,    /// The broad-market day change (the lead index card's move); drives the    /// "S&P +0.9%" stat and feeds the verdict. `None` until that card prices.    broad_pct: Option<f64>,    /// Share of curated stocks trading green, 0..100, rounded; "78% green".    green_pct: Option<u8>,    /// The VIX read folded into one phrase, e.g. "calm at 13.2". `None` until    /// the VIX card prices.    vix_label: Option<String>,}/// One index chip in the hero strip: the resolved index card's ticker and day/// move (cash index during the regular session, its future outside it).#[derive(Serialize)]struct HeroChip {    ticker: String,    change_pct: Option<f64>,    up: bool,}/// Market breadth across the curated large-cap stocks (Phase 5): how many are/// advancing vs declining today and the share green, plus the proportion-bar/// segment widths. Drives the Breadth band and feeds the hero verdict.#[derive(Serialize, Default)]struct Breadth {    advancers: usize,    decliners: usize,    unchanged: usize,    /// Stocks with a computable day change (advancers + decliners + unchanged).    total: usize,    /// Advancers as a percent of `total`, rounded; `None` when `total` is 0.    pct_green: Option<u8>,    /// Proportion-bar segment widths (percent of `total`): green, flat, red.    up_w: f64,    flat_w: f64,    down_w: f64,}/// One curated ETF with everything the dashboard ETF band needs: its price, the/// close it is changing against, and its Phase-4 quality read. Built once and/// fed to both the curated cards and the movers strip.#[derive(Serialize, Clone)]struct EtfRow {    ticker: String,    name: String,    last: Option<f64>,    prev: Option<f64>,    change_pct: Option<f64>,    last_quote_at: Option<i64>,    quality: Option<compute::EtfQuality>,}/// One curated ETF card: its sparkline tile plus the quality verdict (Phase 5).#[derive(Serialize)]struct EtfCard {    card: SparkCard,    quality: Option<compute::EtfQuality>,}/// One pill in the compact ETF movers strip: ticker, day move, quality grade.#[derive(Serialize, Clone)]struct EtfMover {    ticker: String,    change_pct: f64,    quality: Option<compute::EtfQuality>,}/// One row in a movers panel.#[derive(Serialize, Clone)]struct Mover {
@@ -186,9 +281,15 @@ async fn home(State(state): State<AppState>) -> Response {        .unwrap_or(seeded);    let (index_section, commodity_section) = dashboard_cards(&state).await;    // One scan of the curated stocks feeds the movers, the industry composites,    // and the quality leaderboard.    // One scan of the curated stocks feeds breadth, the movers, the industry    // composites, and the quality leaderboard.    let stocks = load_stocks(&state).await;    let breadth = breadth(&stocks);    // The hero blends the lead index card's move, breadth, and the VIX card into    // a one-line read; both sections are already loaded, so it is free.    let hero = build_hero(&index_section, &commodity_section, &breadth);    // The ETF band: curated quality cards plus a compact gainers / losers strip.    let (etf_cards, etf_gainers, etf_losers, etf_asof) = etf_band(&state).await;    let (gainers, losers) = movers(&stocks);    let (healthiest, concerning) = health_panels(&stocks);    let (top_industries, bottom_industries) = industry_panels(&stocks);
@@ -215,6 +316,12 @@ async fn home(State(state): State<AppState>) -> Response {    let extra = minijinja::context! {        title => "Markets",        empty => false,        hero => hero,        breadth => breadth,        etf_cards => etf_cards,        etf_gainers => etf_gainers,        etf_losers => etf_losers,        etf_asof => etf_asof,        index_cards => index_section.cards,        index_asof => index_section.asof,        commodity_cards => commodity_section.cards,
@@ -329,6 +436,291 @@ async fn spark_cards_for(state: &AppState, tickers: &[&str]) -> SparkSection {    SparkSection { cards, asof }}/// Market breadth across the curated large-cap stocks: advancers vs decliners/// today and the share green, plus the proportion-bar segment widths. Reuses/// the already-loaded [`StockRow`] scan — no extra query. A stock without a/// computable change (no price, or a non-positive prior close) is left out of/// every count so a missing quote never reads as "flat".fn breadth(stocks: &[StockRow]) -> Breadth {    let mut b = Breadth::default();    for s in stocks {        let (Some(last), Some(prev)) = (s.last, s.prev) else {            continue;        };        if prev <= 0.0 {            continue;        }        b.total += 1;        match last.partial_cmp(&prev) {            Some(Ordering::Greater) => b.advancers += 1,            Some(Ordering::Less) => b.decliners += 1,            _ => b.unchanged += 1,        }    }    if b.total > 0 {        let total = b.total as f64;        b.pct_green = Some((b.advancers as f64 / total * 100.0).round() as u8);        b.up_w = b.advancers as f64 / total * 100.0;        b.down_w = b.decliners as f64 / total * 100.0;        b.flat_w = (100.0 - b.up_w - b.down_w).max(0.0);    }    b}/// A VIX level read into one plain word. The bands suit the ^VIX cash gauge:/// sub-14 is a placid tape, the teens are normal, the low-20s start to show/// stress, and 28+ is outright fear.fn vix_tone(level: f64) -> &'static str {    match level {        v if v < 14.0 => "calm",        v if v < 20.0 => "steady",        v if v < 28.0 => "elevated",        _ => "stressed",    }}/// Blend the broad-market move, breadth, and the VIX read into the hero's/// two-line verdict. `broad_pct` is the lead index card's day change (the cash/// S&P during the regular session, its future outside it); `green_pct` the/// share of curated stocks green; `vix_level` / `vix_pct` the volatility gauge/// and its move. Returns `(lead, detail)`. A descriptive read of the tape, not/// a forecast — direction comes from the broad move, falling back to breadth/// when the index is flat; width and risk tone colour the wording.fn market_verdict(    broad_pct: Option<f64>,    green_pct: Option<u8>,    vix_level: Option<f64>,    vix_pct: Option<f64>,) -> (String, String) {    // Direction: the broad index move is the headline truth, so the verdict's    // direction tracks its sign — never the opposite of the "S&P +x%" figure    // shown beside it. A near-flat index (|move| < 0.05%) reads as mixed even if    // breadth skews, since that is genuinely a directionless tape. Breadth only    // sets direction when there is no index price at all (e.g. it never quoted).    // 1 up / -1 down / 0 flat.    let dir = match broad_pct {        Some(p) if p > 0.05 => 1,        Some(p) if p < -0.05 => -1,        Some(_) => 0,        None => match green_pct {            Some(g) if g >= 55 => 1,            Some(g) if g <= 45 => -1,            _ => 0,        },    };    // Breadth width: 2 broad / 1 split / 0 narrow.    let width = match green_pct {        Some(g) if g >= 60 => 2,        Some(g) if g <= 40 => 0,        _ => 1,    };    let vix_rising = vix_pct.is_some_and(|p| p > 4.0);    let vix_elevated = vix_level.is_some_and(|v| v >= 20.0);    let lead = match (dir, width) {        (1, 2) if !vix_elevated => "Risk-on, and broad.",        (1, 2) => "Higher across the board.",        (1, 0) => "Higher, but narrow.",        (1, _) => "Modestly higher.",        (-1, _) if vix_rising || vix_elevated => "Risk-off.",        (-1, 0) => "Broadly lower.",        (-1, _) => "Softer today.",        _ => "Quiet, mixed tape.",    };    let move_word = match dir {        1 => "higher",        -1 => "lower",        _ => "little changed",    };    let part_word = match width {        2 => "wide participation",        0 => "narrow participation",        _ => "mixed participation",    };    (lead.to_string(), format!("Markets {move_word} with {part_word}."))}/// Assemble the hero from the already-loaded index + commodity cards and the/// breadth read. The broad-market move is the lead index card (resolved to the/// cash S&P or its future by `dashboard_cards`); the VIX read is the lead/// commodity card (`^VIX` always sits first). The chips mirror the index cards.fn build_hero(index: &SparkSection, commodity: &SparkSection, breadth: &Breadth) -> Hero {    let broad_pct = index.cards.first().and_then(|c| c.change_pct);    let vix = commodity.cards.first();    let vix_level = vix.and_then(|c| c.price);    let vix_pct = vix.and_then(|c| c.change_pct);    let green_pct = breadth.pct_green;    let (verdict, detail) = market_verdict(broad_pct, green_pct, vix_level, vix_pct);    let vix_label = vix_level.map(|v| format!("{} at {:.1}", vix_tone(v), v));    let chips = index        .cards        .iter()        .map(|c| HeroChip {            ticker: c.ticker.clone(),            change_pct: c.change_pct,            up: c.up,        })        .collect();    Hero {        verdict,        detail,        chips,        broad_pct,        green_pct,        vix_label,    }}/// The dashboard ETF band: the curated quality cards and a compact gainers //// losers strip, plus the freshest quote behind them. Builds the full curated/// ETF read once ([`load_etfs`]); the cards reuse the sparkline query for their/// intraday tiles and attach each ETF's quality, and the strip ranks the whole/// curated ETF set by day move.async fn etf_band(state: &AppState) -> (Vec<EtfCard>, Vec<EtfMover>, Vec<EtfMover>, Option<i64>) {    let etfs = load_etfs(state).await;    let quality_by: HashMap<&str, compute::EtfQuality> = etfs        .iter()        .filter_map(|e| e.quality.map(|q| (e.ticker.as_str(), q)))        .collect();    // Curated cards: sparkline tiles for the hardcoded set, each carrying its    // quality verdict. `spark_cards_for` skips a ticker the universe lacks.    let spark = spark_cards_for(state, ETF_CARDS).await;    let etf_cards: Vec<EtfCard> = spark        .cards        .into_iter()        .map(|card| {            let quality = quality_by.get(card.ticker.as_str()).copied();            EtfCard { card, quality }        })        .collect();    let (gainers, losers) = etf_movers(&etfs);    // Freshest quote across the band: the cards' section asof, else the widest    // quote across the curated ETF set.    let etf_asof = spark        .asof        .or_else(|| etfs.iter().filter_map(|e| e.last_quote_at).max());    (etf_cards, gainers, losers, etf_asof)}/// Every curated ETF, each rolled into an [`EtfRow`] with its Phase-4 quality/// read. Mirrors `load_stocks` but for funds: one query for price + the Yahoo/// metadata (expense ratio, NAV) + the SEC AUM, a second for top-10 holdings/// concentration, then `compute::etf_quality`. The tracking factor reads a/// price-vs-NAV premium only against a *fresh* NAV (the daily `fund_nav` job),/// else it drops out — a stale NAV must never assert a bogus premium.async fn load_etfs(state: &AppState) -> Vec<EtfRow> {    type EtfPriceRow = (        String,         // ticker        String,         // name        Option<f64>,    // last        Option<f64>,    // prev        Option<i64>,    // last_quote_at        Option<f64>,    // expense_ratio        Option<f64>,    // nav_price        Option<i64>,    // nav_synced_at        Option<f64>,    // net_assets    );    let rows: Vec<EtfPriceRow> = sqlx::query_as(        "SELECT s.ticker, s.name, \           COALESCE(s.last_price, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \           COALESCE(s.prev_close, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)), \           s.last_quote_at, m.expense_ratio, m.nav_price, m.nav_synced_at, fp.net_assets \         FROM symbols s \         LEFT JOIN fund_metadata m ON m.ticker = s.ticker \         LEFT JOIN fund_profiles fp ON fp.ticker = s.ticker \         WHERE s.is_seeded = 1 AND s.kind = 'etf'",    )    .fetch_all(&state.pool)    .await    .unwrap_or_default();    if rows.is_empty() {        return Vec::new();    }    // Top-10 holdings concentration per ETF (summed weight of the ten largest    // holdings, percent) for the diversification factor. A fund with no    // holdings (a commodity trust) simply misses the map, so that factor drops    // out of its blend rather than reading as zero.    let top10_rows: Vec<(String, Option<f64>)> = sqlx::query_as(        "SELECT ticker, SUM(pct) FROM ( \           SELECT ticker, pct, ROW_NUMBER() OVER (PARTITION BY ticker ORDER BY rank) AS rn \           FROM fund_holdings \         ) WHERE rn <= 10 AND pct IS NOT NULL GROUP BY ticker",    )    .fetch_all(&state.pool)    .await    .unwrap_or_default();    let top10_by: HashMap<String, f64> = top10_rows        .into_iter()        .filter_map(|(t, p)| p.filter(|v| *v > 0.0).map(|v| (t, v)))        .collect();    let now = crate::db::now_ms();    rows.into_iter()        .map(            |(ticker, name, last, prev, last_quote_at, expense_ratio, nav_price, nav_synced_at, net_assets)| {                let change_pct = match (last, prev) {                    (Some(l), Some(p)) if p > 0.0 => Some(compute::change(l, p).pct),                    _ => None,                };                // Tracking factor: read the price-vs-NAV premium only against a                // NAV synced within NAV_FRESH_MS; else let the factor drop out.                let nav_fresh = nav_synced_at.is_some_and(|t| now - t <= NAV_FRESH_MS);                let premium_pct = if nav_fresh {                    last.and_then(|p| compute::premium_discount_pct(p, nav_price))                } else {                    None                };                let top10_pct = top10_by.get(&ticker).copied();                let quality =                    compute::etf_quality(expense_ratio, premium_pct, top10_pct, net_assets);                EtfRow {                    ticker,                    name,                    last,                    prev,                    change_pct,                    last_quote_at,                    quality,                }            },        )        .collect()}/// The day's biggest ETF gainers and losers among the curated funds, each/// carrying its quality grade for a quiet chip. A compact strip (top/// [`ETF_MOVERS_LIMIT`] each), so the band stays scannable beneath the cards.fn etf_movers(etfs: &[EtfRow]) -> (Vec<EtfMover>, Vec<EtfMover>) {    let mut all: Vec<EtfMover> = etfs        .iter()        .filter_map(|e| {            Some(EtfMover {                ticker: e.ticker.clone(),                change_pct: e.change_pct?,                quality: e.quality,            })        })        .collect();    if all.is_empty() {        return (Vec::new(), Vec::new());    }    all.sort_by(|a, b| {        b.change_pct            .partial_cmp(&a.change_pct)            .unwrap_or(Ordering::Equal)    });    let gainers: Vec<EtfMover> = all.iter().take(ETF_MOVERS_LIMIT).cloned().collect();    let losers: Vec<EtfMover> = all.iter().rev().take(ETF_MOVERS_LIMIT).cloned().collect();    (gainers, losers)}/// Every curated large-cap stock, each graded into a [`StockRow`].////// Restricted to `is_seeded` stocks on purpose (see PLAN.md Phase 11): the
modified templates/includes/macros.html
@@ -52,6 +52,48 @@</a>{% endmacro %}{# `etf_card` renders one curated ETF tile (Phase 5): the same intraday   sparkline + live price as `spark_card`, plus the Phase-4 quality verdict as a   small pill in the head so the fund's quality reads at a glance beside its   move. `e.card` is a SparkCard; `e.quality` an EtfQuality (None until ≥2   factors grade). #}{% macro etf_card(e) %}<a class="spark-card{% if e.card.up %} is-up-card{% else %} is-down-card{% endif %}"   href="/s/{{ e.card.ticker|urlencode }}" data-ticker="{{ e.card.ticker }}">  <div class="spark-card__head">    <span class="spark-card__sym">{{ e.card.ticker }}</span>    <span class="spark-card__name">{{ e.card.name }}</span>    {% if e.quality %}<span class="qchip qchip--{{ e.quality.overall }}" title="ETF quality {{ e.quality.percent }}% — cost {{ e.quality.cost_label }}, tracking {{ e.quality.tracking_label }}, diversification {{ e.quality.diversification_label }}, size {{ e.quality.size_label }}">{{ e.quality.verdict }}</span>{% endif %}  </div>  {% if e.card.spark %}  <svg class="spark" viewBox="0 0 100 36" preserveAspectRatio="none"       data-lo="{{ e.card.spark.lo }}" data-hi="{{ e.card.spark.hi }}" aria-hidden="true">    <polygon class="spark__area" points="{{ e.card.spark.area }}"/>    {% if e.card.spark.baseline is not none %}    <line class="spark__base" x1="0" x2="100" y1="{{ e.card.spark.baseline }}" y2="{{ e.card.spark.baseline }}"/>    {% endif %}    <polyline class="spark__line" points="{{ e.card.spark.line }}"/>  </svg>  {% else %}  <div class="spark spark--empty">no intraday data</div>  {% endif %}  <div class="spark-card__foot">    <span class="spark-card__price num" data-field="price">{{ e.card.price|money }}</span>    <span class="spark-card__chg num{% if e.card.change_pct is none %} is-flat{% elif e.card.change_pct >= 0 %} is-up{% else %} is-down{% endif %}" data-field="change_pct">{{ e.card.change_pct|pct }}</span>  </div></a>{% endmacro %}{# `etf_pill` renders one compact ETF mover (Phase 5): ticker + day move, with a   small quality dot tinting the pill by the fund's overall grade. #}{% macro etf_pill(m) %}<a class="etf-pill {{ 'is-up' if m.change_pct >= 0 else 'is-down' }}" href="/s/{{ m.ticker|urlencode }}">  {% if m.quality %}<span class="etf-pill__dot etf-pill__dot--{{ m.quality.overall }}" aria-hidden="true"></span>{% endif %}  <span class="etf-pill__sym">{{ m.ticker }}</span>  <span class="etf-pill__chg num">{{ m.change_pct|pct }}</span></a>{% endmacro %}{# `mover_row` renders one gainer/loser row. The `--bar` custom property sizes   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
modified templates/pages/home.html
@@ -15,13 +15,40 @@       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, health_row %}  {% from "includes/macros.html" import spark_card, etf_card, etf_pill, mover_row, health_row %}  <div class="page-head">    <h1>Markets</h1>    <a class="page-head__link" href="/search">Browse all {{ total }} symbols</a>  </div>  {# Hero (Phase 5): a one-line plain read of the day — the broad move, breadth,     and the VIX risk tone blended into a verdict — over a compact index strip.     A page-load snapshot: the verdict is descriptive, not a forecast, so the     non-advice note rides with it. #}  <section class="hero">    <div class="hero__read">      <p class="hero__verdict">{{ hero.verdict }}</p>      <p class="hero__detail">{{ hero.detail }}</p>      <p class="hero__stats">        {% if hero.broad_pct is not none %}<span class="hero__stat">S&amp;P <span class="num {{ 'is-up' if hero.broad_pct >= 0 else 'is-down' }}">{{ hero.broad_pct|pct }}</span></span>{% endif %}        {% if hero.green_pct is not none %}<span class="hero__stat"><span class="num">{{ hero.green_pct }}%</span> of the S&amp;P green</span>{% endif %}        {% if hero.vix_label %}<span class="hero__stat">VIX {{ hero.vix_label }}</span>{% endif %}      </p>      <p class="hero__note">A plain read of today&rsquo;s tape &mdash; not investment advice.</p>    </div>    {% if hero.chips %}    <div class="hero__chips">      {% for c in hero.chips %}      <a class="hero__chip {{ 'is-up' if c.up else 'is-down' }}" href="/s/{{ c.ticker|urlencode }}">        <span class="hero__chip-sym">{{ c.ticker }}</span>        <span class="hero__chip-chg num">{{ c.change_pct|pct }}</span>      </a>      {% endfor %}    </div>    {% endif %}  </section>  {# The sparkline-card sections stream live, so their freshness time rides in     a data-field the stream client refreshes as quotes land (Phase 22). The     movers / standings panels below are fixed page-load snapshots, so their
@@ -31,10 +58,52 @@    {% for c in index_cards %}{{ spark_card(c) }}{% endfor %}  </div>  <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>  {# Breadth band (Phase 5): how many curated S&P stocks are up vs down today,     the share green, and a proportion bar of the split. A page-load snapshot. #}  {% if breadth.total %}  <h2 class="section-title">Breadth{% if movers_asof %}<span class="section-title__asof">prices as of {{ movers_asof|asof }}</span>{% endif %}</h2>  <p class="section-note">How many of the curated large-cap stocks are advancing     versus declining today, and the share trading green.</p>  <div class="breadth">    <div class="breadth__stats">      <span class="breadth__stat breadth__stat--up"><span class="breadth__num num">{{ breadth.advancers }}</span> advancing</span>      <span class="breadth__stat breadth__stat--down"><span class="breadth__num num">{{ breadth.decliners }}</span> declining</span>      {% if breadth.unchanged %}<span class="breadth__stat breadth__stat--flat"><span class="breadth__num num">{{ breadth.unchanged }}</span> flat</span>{% endif %}      {% if breadth.pct_green is not none %}<span class="breadth__pct num">{{ breadth.pct_green }}% green</span>{% endif %}    </div>    <div class="breadth__bar" role="img"         aria-label="{{ breadth.advancers }} advancing, {{ breadth.decliners }} declining of {{ breadth.total }} stocks">      <span class="breadth__seg breadth__seg--up" style="width: {{ breadth.up_w }}%"></span>      <span class="breadth__seg breadth__seg--flat" style="width: {{ breadth.flat_w }}%"></span>      <span class="breadth__seg breadth__seg--down" style="width: {{ breadth.down_w }}%"></span>    </div>  </div>  {% endif %}  {# ETF band (Phase 5): curated quality cards spanning the asset classes, then     a compact gainers / losers strip across the whole curated ETF set. #}  <h2 class="section-title">ETFs{% if etf_asof %}<span class="section-title__asof">prices as of <span data-field="spark-asof">{{ etf_asof|asof }}</span></span>{% endif %}<span class="section-title__asof"><a href="/search?kind=etf">All ETFs &rarr;</a></span></h2>  <p class="section-note">A quality read per fund &mdash; cost, tracking,     diversification, and size, blended. Hover the verdict for the breakdown.</p>  {% if etf_cards %}  <div class="spark-grid">    {% for c in commodity_cards %}{{ spark_card(c) }}{% endfor %}    {% for e in etf_cards %}{{ etf_card(e) }}{% endfor %}  </div>  {% else %}  <p class="movers__empty">No curated ETFs yet.</p>  {% endif %}  {% if etf_gainers or etf_losers %}  <div class="etf-movers">    <div class="etf-movers__group etf-movers__group--up">      <span class="etf-movers__label">Gainers</span>      {% for m in etf_gainers %}{{ etf_pill(m) }}{% endfor %}    </div>    <div class="etf-movers__group etf-movers__group--down">      <span class="etf-movers__label">Losers</span>      {% for m in etf_losers %}{{ etf_pill(m) }}{% endfor %}    </div>  </div>  {% endif %}  <h2 class="section-title">Today&rsquo;s movers{% if movers_asof %}<span class="section-title__asof">prices as of {{ movers_asof|asof }}</span>{% endif %}</h2>  <div class="movers">
@@ -98,6 +167,11 @@    </section>  </div>  <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>  <h2 class="section-title">Quality leaderboard{% if health_asof %}<span class="section-title__asof">last sync {{ health_asof|ago }}</span>{% endif %}</h2>  <p class="section-note">One quality read per curated stock: fundamentals +     recent price and growth trajectory + leadership stability, rolled together.