repos

Phase 3: drop short-horizon prediction, add quality leaderboard

89212d34 by Isaac Bythewood · 1 month ago

Phase 3: drop short-horizon prediction, add quality leaderboard

Remove the whole picker: the four horizon rankers (pick_day/week/month/
quarter + PickInput), src/picks.rs, the /backtest route, page, and JS, the
scheduler's once-a-day snapshot job, and the backtest-only models helpers
(latest_annual_inputs_as_of, FILING_LAG_DAYS). Migration 0013 drops the picks
table and sweeps its stale data_status/fetch_log/meta rows.

Merge the three overlapping home panels (Top picks, Strongest & weakest, Stock
health) into one non-advice Quality leaderboard (Healthiest / Most concerning)
driven by the existing health_read composite, with each row's trailing-year
return folded in as a price anchor. compute::standing stays: movers uses it for
the strength badge, and the symbol and search pages use it too.
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 2 complete on dev)__Last updated: 2026-05-30 (Phase 3 complete + deployed)_**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,34 @@ 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:** Phase 2 (universe curation) is **complete and verified on thedev box**; commit + deploy pending. Next: **Phase 3 (drop short-horizonprediction → quality leaderboard)**.Phase 2 outcome:**Current work:** Phases 1–3 are **done and deployed.** Next: **Phase 4 (ETFsas true first-class citizens)**.Phase 3 outcome (drop short-horizon prediction → quality leaderboard):- **The whole picker is gone.** Removed the four horizon rankers  (`pick_day/week/month/quarter` + `PickInput`), `src/picks.rs`, the `/backtest`  route + page + JS, the scheduler's once-a-day snapshot job, and the  backtest-only `models::latest_annual_inputs_as_of` / `FILING_LAG_DAYS`.  Migration `0013_drop_picks.sql` drops the `picks` table and sweeps its stale  `data_status` / `fetch_log` / `meta` rows so `/health` doesn't show a frozen  "picks" job.- **Three overlapping home panels merged into one Quality leaderboard.** Top  picks, Strongest & weakest, and Stock health all collapsed into a single  non-advice "Healthiest / Most concerning" surface driven by the existing  `compute::health_read` composite (fundamentals + trajectory + leadership  stability). The old strongest/weakest panel's trailing-year return is folded  into each leaderboard row as a quiet price anchor; the non-advice disclaimer  rides on the section.- **`compute::standing` kept** — the movers panel still uses it for each row's  strength badge, and the symbol + search pages use it too. Only the home  *strongest/weakest panel* was removed, not the standing read itself.- **Verified:** `cargo build` + `vite build` clean (no `backtest` entry);  migration applied on boot (picks table dropped, zero stale picks rows,  `data_status` job list no longer lists picks); `/backtest` + `/api/backtest`  → 404; `/api/health` → 200 with no picks job; home renders the leaderboard  with trailing returns and none of the removed panels (screenshot reviewed).Phase 2 outcome (universe curation, deployed in `b016b25`):- **Stocks reconciled to the current S&P 500.** Fetched the live Wikipedia  constituent list (503) and diffed against ours: an exact match, zero changes  needed — the stock list was already current.
@@ -224,12 +247,17 @@ Kill the rate-limit problem at the root.- ✅ Fixed Yahoo `range=max` downsampling (provider 10y fallback + `run_history`  self-heal); every seeded symbol now holds true daily bars.### Phase 3 — Drop short-horizon prediction → quality leaderboard + home de-dup- Remove Day/Week picks and the short-horizon backtest machinery.- Reframe Month/Quarter into a single non-advice **quality leaderboard**  ("healthiest / strongest right now"), merging the overlapping home panels  (Top picks, Stock health Healthiest/Concerning, Strongest & weakest) into one  coherent surface. Slim or drop the `picks` table + `/backtest` accordingly.### Phase 3 — Drop short-horizon prediction → quality leaderboard + home de-dup  ✅ DONE (deployed 2026-05-30)- ✅ Removed all four pick rankers (Day/Week *and* Month/Quarter), the backtest  machinery, the scheduler snapshot job, and the backtest-only models helpers.- ✅ Merged Top picks + Stock health + Strongest & weakest into a single  non-advice **Quality leaderboard** (Healthiest / Most concerning) driven by  `compute::health_read`, trailing-year return folded into each row.- ✅ Dropped the `picks` table via migration `0013_drop_picks.sql` (+ swept  stale status rows). `compute::standing` retained for the movers badge +  symbol/search pages.- 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- A blended ETF **quality score** (cost / diversification / size / tracking)
@@ -275,6 +303,20 @@ Kill the rate-limit problem at the root.## Decisions log**2026-05-30 — Phase 3 (drop short-horizon prediction → quality leaderboard).**Executed the kickoff decision to drop prediction. Resolved the one open designfork (what the unified leaderboard ranks by) without blocking: used the existing`health_read` composite — it is the "healthiest" read the plan named and alreadyblends strength + trajectory + leadership stability — rather than inventing a newscore or reusing `standing`. Folded the old strongest/weakest panel'strailing-year return into each leaderboard row so no useful signal was lost.Kept the home-page treatment deliberately light (a straight three-into-onede-dup) because Phase 5 redesigns the dashboard around this band anyway. Foundduring the work: `movers` reuses `compute::standing` for its strength badge, so`standing` stays (only the strongest/weakest *panel* was removed); and therunning dev server had to be restarted to pick up the new binary + run thedrop-picks migration.**2026-05-30 — Phase 2 (universe curation).** Answered 4 clarifying questions:1. **ETF roster:** keep iShares + Vanguard + the SPY/QQQ/DIA/GLD/SLV proxies;   drop other-issuer thematic/sector funds; add the most-common Vanguard +
deleted frontend/static_src/backtest/index.js
@@ -1,242 +0,0 @@// Backtest page (Phase 30).//// Renders one horizon at a time inside #bt-panel: a stats panel, an equity// curve drawn with lightweight-charts (strategy + ^SPX benchmark), and a// table of every rebalance period's picks and returns. Switching tabs runs// the same renderer against a fresh /api/backtest fetch, so the page never// reloads.import "./styles/backtest.scss";import {  createChart,  AreaSeries,  LineSeries,  ColorType,} from "lightweight-charts";const STRAT_LINE = "#2f7d4f"; // ink-green; matches the home up paletteconst STRAT_FILL_TOP = "rgba(47, 125, 79, 0.20)";const STRAT_FILL_BTM = "rgba(47, 125, 79, 0.00)";const BENCH_LINE = "#7a5237"; // wayfinding ink-brown (Phase 8 indicator palette)const chartOptions = {  autoSize: true,  handleScroll: false,  handleScale: false,  layout: {    background: { type: ColorType.Solid, color: "transparent" },    textColor: "#6b6456",    fontFamily: "'JetBrains Mono', monospace",    attributionLogo: false,  },  grid: {    vertLines: { color: "rgba(33,31,26,0.07)" },    horzLines: { color: "rgba(33,31,26,0.07)" },  },  rightPriceScale: { borderColor: "rgba(33,31,26,0.16)" },  timeScale: { borderColor: "rgba(33,31,26,0.16)", rightOffset: 0 },  crosshair: { mode: 1 },};const panel = document.getElementById("bt-panel");const tabs = document.querySelectorAll(".bt-tab");let current = "month";function setActive(horizon) {  current = horizon;  tabs.forEach((t) => {    t.classList.toggle("is-active", t.dataset.horizon === horizon);    t.setAttribute("aria-selected", t.dataset.horizon === horizon);  });}// Phase 31: a small skeleton (4 stat bones + a chart bone + status text)// shown while the JSON loads, instead of bare "Loading backtest…" text on// empty paper. The first render keeps the server-rendered skeleton; later// tab switches re-create it so each horizon change shows the skeleton too.function skeletonHtml() {  return `    <div class="bt-skeleton" data-role="skeleton">      <div class="bt-skeleton__stats">        <div class="bt-skeleton__stat"></div>        <div class="bt-skeleton__stat"></div>        <div class="bt-skeleton__stat"></div>        <div class="bt-skeleton__stat"></div>      </div>      <div class="bt-skeleton__chart"></div>      <p class="bt-status">Loading backtest&hellip;</p>    </div>`;}function load(horizon) {  setActive(horizon);  // Reuse the existing server-rendered skeleton on the first load (no  // flicker / paint cost); regenerate it on each subsequent horizon  // change so the loading affordance is consistent.  if (!panel.querySelector("[data-role=skeleton]")) {    panel.innerHTML = skeletonHtml();  }  fetch(`/api/backtest?horizon=${encodeURIComponent(horizon)}`)    .then((res) => {      if (!res.ok) throw new Error(`backtest ${res.status}`);      return res.json();    })    .then(render)    .catch((err) => {      panel.innerHTML = `<p class="bt-status bt-status--err">Backtest failed: ${err.message}</p>`;      console.error(err);    });}function render(d) {  if (!d.stats || d.equity.length < 2) {    panel.innerHTML = `      <p class="bt-status">        Not enough history for a <strong>${escape(d.horizon.label)}</strong>        backtest yet. ${escape(d.horizon.desc)}.      </p>`;    return;  }  const s = d.stats;  // Header summary: which horizon, over what window, with how many  // rebalances. The signal-description rides under as a quiet note so the  // reader knows what they are looking at without scrolling.  const head = `    <header class="bt-head">      <h2 class="bt-head__title">${escape(d.horizon.label)} horizon</h2>      <p class="bt-head__desc">${escape(d.horizon.desc)}.</p>      <p class="bt-head__window">        ${escape(s.period_start)} &rarr; ${escape(s.period_end)} &middot;        ${s.num_periods} rebalances &middot; ${s.num_picks} picks total      </p>    </header>`;  // The four headline figures: strategy total return, benchmark total  // return, per-pick win rate, per-period win rate. Each is a small card  // with the figure and a quiet label.  const stratGood = s.total_return_pct >= s.benchmark_return_pct;  const stats = `    <div class="bt-stats">      <div class="bt-stat">        <div class="bt-stat__label">Strategy total</div>        <div class="bt-stat__val num ${stratGood ? "is-up" : "is-down"}">${pct(s.total_return_pct)}</div>        <div class="bt-stat__sub">$${fmtUsd(d.starting_capital)} &rarr; <strong>$${fmtUsd(s.final_strategy)}</strong> &middot; ${pct(s.cagr_pct)}/yr</div>      </div>      <div class="bt-stat">        <div class="bt-stat__label">${escape(d.bench_ticker)} benchmark</div>        <div class="bt-stat__val num">${pct(s.benchmark_return_pct)}</div>        <div class="bt-stat__sub">$${fmtUsd(d.starting_capital)} &rarr; <strong>$${fmtUsd(s.final_benchmark)}</strong> &middot; ${pct(s.benchmark_cagr_pct)}/yr</div>      </div>      <div class="bt-stat">        <div class="bt-stat__label">Per-pick win rate</div>        <div class="bt-stat__val num">${winPct(s.per_pick_win_rate)}</div>        <div class="bt-stat__sub">share of individual picks that closed up</div>      </div>      <div class="bt-stat">        <div class="bt-stat__label">Beat benchmark</div>        <div class="bt-stat__val num">${winPct(s.per_period_win_rate)}</div>        <div class="bt-stat__sub">share of periods the basket beat ${escape(d.bench_ticker)}</div>      </div>    </div>`;  // History table: one row per rebalance, picks listed compactly. The  // newest rebalance comes first so the most recent test is at the top.  const rows = d.periods    .slice()    .reverse()    .map((p) => {      const picks = p.picks        .map(          (pick) =>            `<a class="bt-pick ${pick.return_pct >= 0 ? "is-up" : "is-down"}"                href="/s/${encodeURIComponent(pick.ticker)}"                title="${escape(pick.ticker)}: ${pct(pick.return_pct)}">               ${escape(pick.ticker)}<span class="bt-pick__ret">${pct(pick.return_pct)}</span>             </a>`,        )        .join("");      return `        <tr class="${p.beat_benchmark ? "is-beat" : ""}">          <td class="num">${escape(p.start_date)}</td>          <td class="num">${escape(p.end_date)}</td>          <td class="bt-row__picks">${picks}</td>          <td class="num ${p.basket_return_pct >= 0 ? "is-up" : "is-down"}">${pct(p.basket_return_pct)}</td>          <td class="num">${pct(p.benchmark_return_pct)}</td>        </tr>`;    })    .join("");  const history = `    <h3 class="bt-section">Rebalance history</h3>    <div class="bt-table-wrap">      <table class="bt-table">        <thead>          <tr><th>Start</th><th>End</th><th>Picks</th>              <th>Basket</th><th>${escape(d.bench_ticker)}</th></tr>        </thead>        <tbody>${rows}</tbody>      </table>    </div>`;  panel.innerHTML = `    ${head}    ${stats}    <h3 class="bt-section">Equity curve</h3>    <div id="bt-chart" class="bt-chart"></div>    ${history}`;  // Draw the chart after the DOM is in place.  const el = document.getElementById("bt-chart");  const chart = createChart(el, chartOptions);  const strat = chart.addSeries(AreaSeries, {    lineColor: STRAT_LINE,    topColor: STRAT_FILL_TOP,    bottomColor: STRAT_FILL_BTM,    lineWidth: 2,    priceFormat: { type: "custom", formatter: fmtCompactUsd, minMove: 1 },  });  strat.setData(d.equity.map((p) => ({ time: p.date, value: p.strategy })));  const bench = chart.addSeries(LineSeries, {    color: BENCH_LINE,    lineWidth: 2,    lineStyle: 2,    priceFormat: { type: "custom", formatter: fmtCompactUsd, minMove: 1 },    priceLineVisible: false,  });  bench.setData(d.equity.map((p) => ({ time: p.date, value: p.benchmark })));  chart.timeScale().fitContent();}// ── small formatters ──function pct(n) {  if (n == null || !Number.isFinite(n)) return "—";  const sign = n > 0 ? "+" : "";  return `${sign}${n.toFixed(2)}%`;}function winPct(n) {  if (n == null || !Number.isFinite(n)) return "—";  return `${n.toFixed(1)}%`;}function fmtUsd(n) {  return n.toLocaleString("en-US", { maximumFractionDigits: 0 });}function fmtCompactUsd(n) {  const abs = Math.abs(n);  if (abs >= 1e9) return `$${(n / 1e9).toFixed(1)}B`;  if (abs >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;  if (abs >= 1e3) return `$${(n / 1e3).toFixed(1)}K`;  return `$${n.toFixed(0)}`;}// HTML-escape any user-visible string injected into innerHTML.function escape(s) {  return String(s).replace(/[&<>"']/g, (c) => ({    "&": "&amp;",    "<": "&lt;",    ">": "&gt;",    '"': "&quot;",    "'": "&#39;",  })[c]);}tabs.forEach((t) => t.addEventListener("click", () => load(t.dataset.horizon)));load(current);
deleted frontend/static_src/backtest/styles/backtest.scss
@@ -1,258 +0,0 @@@use "../../base/styles/variables" as *;@use "../../base/styles/mixins" as *;/* ============================================================================   Backtest page (Phase 30)   A single page that re-runs the picker over historical prices and shows the   resulting equity curve, headline stats, and a per-rebalance history table.   Horizon tabs swap the rendered content in place via the JSON endpoint.   ========================================================================== */.bt-blurb {  margin: var(--sp-3) 0 var(--sp-1);  max-width: 70ch;  color: var(--ink-dim);  font-size: var(--fs-md);  line-height: 1.55;}/* horizon tabs (Day / Week / Month / Year) */.bt-tabs {  display: flex;  gap: var(--sp-1);  margin: var(--sp-4) 0 var(--sp-3);  border-bottom: 1px solid var(--rule-strong);}.bt-tab {  font-family: var(--font-serif);  font-weight: 600;  font-size: var(--fs-md);  letter-spacing: -0.01em;  padding: var(--sp-2) var(--sp-3);  background: transparent;  border: 0;  border-bottom: 2px solid transparent;  color: var(--ink-faint);  cursor: pointer;  margin-bottom: -1px;  transition: color 0.1s, border-color 0.1s;}.bt-tab:hover {  color: var(--ink);}.bt-tab.is-active {  color: var(--ink);  border-bottom-color: var(--ink);}/* loading / error state */.bt-status {  padding: var(--sp-5) 4px;  color: var(--ink-faint);  font-size: var(--fs-sm);  text-align: center;}.bt-status--err {  color: var(--down);}/* loading skeleton — a 4-up stat strip + chart-sized bone, shown before   the JSON snapshot lands so the page doesn't collapse into bare text.   A subtle shimmer hints at activity; the bones share the same   geometry as the real layout so the page doesn't reflow when data   arrives. */.bt-skeleton {  display: grid;  gap: var(--sp-4);  margin: var(--sp-3) 0;}.bt-skeleton__stats {  display: grid;  grid-template-columns: repeat(2, minmax(0, 1fr));  gap: var(--sp-3);}@media (min-width: $bp-md) {  .bt-skeleton__stats {    grid-template-columns: repeat(4, minmax(0, 1fr));  }}.bt-skeleton__stat,.bt-skeleton__chart {  background: linear-gradient(90deg,    var(--well) 0%,    rgba(231, 225, 210, 0.55) 50%,    var(--well) 100%);  background-size: 200% 100%;  border-radius: var(--radius);  animation: bt-shimmer 1.4s ease-in-out infinite;}.bt-skeleton__stat   { height: 78px; }.bt-skeleton__chart  { height: 360px; }@keyframes bt-shimmer {  0%   { background-position: 100% 0; }  100% { background-position: -100% 0; }}@media (prefers-reduced-motion: reduce) {  .bt-skeleton__stat,  .bt-skeleton__chart {    animation: none;  }}.bt-head {  margin: 12px 0 18px;}.bt-head__title {  margin: 0;  font-size: 1.2rem;}.bt-head__desc {  margin: 4px 0 2px;  color: var(--ink-dim);  font-size: 0.84rem;}.bt-head__window {  @include mono;  margin: 0;  color: var(--ink-faint);  font-size: 0.74rem;}/* stat cards */.bt-stats {  display: grid;  grid-template-columns: repeat(2, minmax(0, 1fr));  gap: 12px;  margin-bottom: 22px;}.bt-stat {  @include card;  padding: 12px 14px 10px;  display: flex;  flex-direction: column;  gap: 4px;}.bt-stat__label {  @include eyebrow;  color: var(--ink-dim);}.bt-stat__val {  font-size: 1.45rem;  font-weight: 700;}.bt-stat__sub {  font-size: 0.74rem;  color: var(--ink-faint);}@media (min-width: $bp-md) {  .bt-stats {    grid-template-columns: repeat(4, minmax(0, 1fr));  }}/* section heading inside the rendered panel */.bt-section {  @include eyebrow;  margin: 24px 0 10px;  padding-bottom: 6px;  border-bottom: 1px solid var(--rule-strong);}/* equity chart */.bt-chart {  @include card;  height: 360px;  padding: 8px;}/* per-rebalance history table */.bt-table-wrap {  @include card;  overflow-x: auto;  padding: 0;}.bt-table {  width: 100%;  border-collapse: collapse;  font-size: 0.78rem;}.bt-table th {  @include eyebrow;  text-align: left;  padding: 10px 12px;  border-bottom: 1px solid var(--rule-strong);  color: var(--ink-dim);  background: var(--well);}.bt-table td {  padding: 9px 12px;  border-bottom: 1px solid var(--rule);  vertical-align: middle;}.bt-table tr:last-child td {  border-bottom: 0;}/* a quiet up-green sliver on rows where the basket beat the benchmark */.bt-table tr.is-beat td:first-child {  box-shadow: inset 2px 0 0 var(--up);}.bt-row__picks {  display: flex;  flex-wrap: wrap;  gap: 5px;}.bt-pick {  @include mono;  display: inline-flex;  align-items: center;  gap: 4px;  padding: 2px 6px;  border-radius: var(--radius-sm);  font-size: 0.7rem;  font-weight: 600;  background: var(--well);  color: var(--ink);  white-space: nowrap;}.bt-pick.is-up {  background: var(--up-soft);  color: var(--up);}.bt-pick.is-down {  background: var(--down-soft);  color: var(--down);}.bt-pick__ret {  font-size: 0.66rem;  font-weight: 500;  opacity: 0.85;}
modified frontend/static_src/base/styles/_mixins.scss
@@ -39,11 +39,10 @@  color: var(--ink-faint);}// Shared ranked-row chrome (Phase 31). Used by `.mover`, `.standing`,// `.hrow`, `.industry-row`, and `.pick__row`. Each row is a grid with a// magnitude-tinted ::before sized by the route via the --bar custom// property; hover swaps in the well. The grid template is the row's job —// this mixin only owns the chrome.// Shared ranked-row chrome (Phase 31). Used by `.mover`, `.hrow`, and// `.industry-row`. Each row is a grid with a magnitude-tinted ::before sized// by the route via the --bar custom property; hover swaps in the well. The// grid template is the row's job — this mixin only owns the chrome.@mixin ranked-row {  position: relative;  display: grid;
modified frontend/static_src/base/styles/base.scss
@@ -720,10 +720,10 @@ main {  line-height: 1.55;}/* ---------- disclaimer line (Phase 30) ---------- *//* A quiet ink-faint line used on the home Top picks panel and the /backtest   page header. Always smaller than body text; never carries a semantic   colour — the picks themselves are explicitly "for fun and testing". *//* ---------- disclaimer line ---------- *//* A quiet ink-faint line used on the home quality leaderboard. Always smaller   than body text; never carries a semantic colour — the read is explicitly   "not investment advice". */.disclaimer {  margin: calc(var(--sp-2) * -1) 0 var(--sp-3);  font-size: var(--fs-xs);
modified frontend/static_src/home/styles/home.scss
@@ -235,49 +235,17 @@  display: flex;}/* ---------- strongest / weakest panels (Phase 20) ---------- *//* Same ranked-row chrome as .mover; rank is the rolled-up standing rather   than the day's price move. */.standing {  @include ranked-row;  /* sym, name, trailing-year return, verdict badge */  grid-template-columns: auto minmax(0, 1fr) auto auto;}.standing--up::before    { background: var(--up-soft); }.standing--down::before  { background: var(--down-soft); }.standing__sym {  font-family: var(--font-mono);  font-weight: 700;  font-size: var(--fs-sm);}.standing__name {  font-size: var(--fs-xs);  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-family: var(--font-mono);  font-size: var(--fs-xs);  color: var(--ink-faint);  text-align: right;  min-width: 5ch;}/* ---------- healthiest / most-concerning panels (Phase 17) ---------- *//* ---------- quality leaderboard: healthiest / most-concerning (Phase 17,   reframed Phase 3) ---------- *//* Each row carries the symbol, the name with three sub-component chips   underneath (fundamentals / trajectory / leadership), and the overall   Healthy / Mixed / Concerning verdict on the right. The chips read as   coloured pills so the synthesis is legible without expanding. */   underneath (fundamentals / trajectory / leadership), the trailing-year   return as a quiet price anchor, and the overall Healthy / Mixed / Concerning   verdict on the right. The chips read as coloured pills so the synthesis is   legible without expanding. */.hrow {  @include ranked-row;  grid-template-columns: auto minmax(0, 1fr) auto;  /* sym, body (name + chips), trailing-year return, verdict badge */  grid-template-columns: auto minmax(0, 1fr) auto auto;}.hrow--up::before    { background: var(--up-soft); }
@@ -329,150 +297,13 @@.hrow__chip--bad      { color: var(--down);      background: var(--down-soft); }.hrow__chip--unknown  { color: var(--ink-faint); background: var(--well); }/* ---------- top picks panel (Phase 30) ---------- *//* Four columns (Day / Week / Month / Year), each carrying 5 ranked picks.   On a phone the four stack 2-up so a quick read of all horizons stays   above the fold; on desktop they spread out into one row. */.picks-grid {  display: grid;  /* Single column at the narrowest widths: a 2-up grid clipped the score     column on a 390px phone, and stacking buys back room for the company     name. 2-up reinstated at $bp-sm where each card has ~280px to work with. */  grid-template-columns: minmax(0, 1fr);  gap: 12px;}/* Mobile baseline: a plain block card. Each card stacks vertically and   its own children flow normally so the title/desc/list sequence reads   top-to-bottom. The subgrid alignment kicks in at $bp-sm once we're   back to a multi-column layout where lining rows up matters. */.picks-col {  @include card;  padding: 10px 12px 8px;  min-width: 0;}/* The picks-col title is a quiet header inside an already-bordered card,   so a serif label without an extra rule (the card border owns that). */.picks-col__title {  font-family: var(--font-serif);  font-weight: 600;  font-size: var(--fs-md);  letter-spacing: -0.01em;  color: var(--ink);  padding-bottom: var(--sp-1);}.picks-col__desc {  margin: 0;  font-size: var(--fs-2xs);  color: var(--ink-faint);  line-height: 1.4;}.picks-col__list {  list-style: none;  margin: var(--sp-1) 0 0;  padding: 0;}.picks-col__empty {  margin: var(--sp-2) 0;  font-size: var(--fs-xs);  color: var(--ink-faint);}.pick {  border-bottom: 1px solid var(--rule);}.pick:last-child {  border-bottom: 0;}/* Phone baseline: the card is full-width, so the row carries the company   name in its own slot. At $bp-sm and up the row drops the name (no longer   enough horizontal room once the grid goes 2-up / 4-up) and shifts the   verdict badge into the flexible track instead. */.pick__row {  display: grid;  grid-template-columns: 1.5ch minmax(4.5ch, auto) minmax(0, 1fr) auto minmax(5.6ch, auto);  align-items: center;  gap: var(--sp-2);  padding: 9px 2px;  color: var(--ink);  min-height: 36px;}.pick__row:hover {  color: var(--ink);  background: var(--well);}.pick__rank {  text-align: right;  font-family: var(--font-mono);  font-size: var(--fs-2xs);  color: var(--ink-faint);  font-weight: 600;}.pick__sym {/* the trailing-year return, a quiet context figure beside the verdict */.hrow__ret {  font-family: var(--font-mono);  font-weight: 700;  font-size: var(--fs-sm);}/* The name span shows on phones, where the stacked 1-up card has room for   it, and is hidden once the grid steps up to 2-up and 4-up below   (the slot becomes too narrow for a useful name slice). */.pick__name {  color: var(--ink-faint);  font-size: var(--fs-xs);  min-width: 0;  white-space: nowrap;  overflow: hidden;  text-overflow: ellipsis;}/* center the verdict pill in its slot so Strong / Fair / Weak align   horizontally across the four cards */.pick__badge {  display: flex;  justify-content: flex-start;  margin-left: auto;}.pick__score {  font-family: var(--font-mono);  font-size: var(--fs-sm);  font-weight: 700;  color: var(--ink-faint);  text-align: right;}/* ---------- $bp-sm: picks step up to 2-up; name yields to the badge ---------- */@media (min-width: $bp-sm) {  .picks-grid {    grid-template-columns: repeat(2, minmax(0, 1fr));  }  /* Reinstate the cross-card row alignment now that there's more than one     column to align across. */  .picks-col {    display: grid;    grid-template-columns: subgrid;    grid-template-rows: subgrid;    grid-row: span 3;    row-gap: 4px;  }  .pick__row {    grid-template-columns: 1.5ch minmax(4.5ch, auto) 1fr minmax(5.6ch, auto);  }  .pick__name {    display: none;  }  min-width: 5ch;}/* ---------- desktop layering ---------- */
@@ -480,10 +311,6 @@  .movers {    grid-template-columns: 1fr 1fr;  }  .picks-grid {    grid-template-columns: repeat(4, minmax(0, 1fr));  }}/* on a narrow phone the mover price yields to the badge and day change, the
@@ -501,8 +328,8 @@/* ---------- Today's industries panel (Phase 15) ---------- *//* Each row is one sector: name on the left, member count, and the day   composite %. Same chrome as `.mover` and `.standing` via the   ranked-row mixin. */   composite %. Same chrome as `.mover` and `.hrow` via the ranked-row   mixin. */.industry-row {  @include ranked-row;  grid-template-columns: minmax(0, 1fr) auto auto;
modified frontend/vite.config.js
@@ -18,7 +18,6 @@ export default defineConfig({        symbol: resolve(__dirname, "static_src/symbol/index.js"),        health: resolve(__dirname, "static_src/health/index.js"),        search: resolve(__dirname, "static_src/search/index.js"),        backtest: resolve(__dirname, "static_src/backtest/index.js"),        industries: resolve(__dirname, "static_src/industries/index.js"),      },    },
added migrations/0013_drop_picks.sql
@@ -0,0 +1,10 @@-- Phase 3 drops short-horizon prediction. The Day/Week/Month/Quarter pickers,-- the /backtest page, and the once-a-day snapshot job that fed it are all gone;-- the home page now reads quality off the existing health composite. Nothing-- writes or reads the `picks` table any more, so remove it and sweep the stale-- scheduler bookkeeping the job left in the status tables (otherwise /health-- would keep showing a "picks" job frozen at its last run forever).DROP TABLE IF EXISTS picks;DELETE FROM data_status WHERE job = 'picks';DELETE FROM fetch_log   WHERE job = 'picks';DELETE FROM meta        WHERE key = 'picks_snapshot_date';
modified src/app.rs
@@ -101,7 +101,6 @@ pub fn router(state: AppState) -> Router {    Router::new()        .merge(routes::home::router())        .merge(routes::backtest::router())        .merge(routes::industries::router())        .merge(routes::symbols::router())        .merge(routes::search::router())
modified src/compute.rs
@@ -1260,215 +1260,6 @@ pub fn premium_grade(premium_pct: f64) -> Grade {    }}// ─────────────────────── forecast-horizon picks (Phase 30) ────────────────//// The four rankers behind the home page's "Top picks" panel and the /backtest// page. Each takes the same per-stock input bundle and returns a score for// one horizon — higher is better, `None` when the stock is disqualified// (missing data, fails a filter). The caller (`picks::compute_picks`) ranks// every curated stock descending and keeps the top 5 per horizon.//// The score is the headline figure that justified the pick (intraday return// for `day`, 5d/20d return for `week` / `month`, the Phase 20 combined score// for `year`), so the home panel and the backtest can both display it// straight without a second computation. Stocks-only across all four horizons// per the user's design call — fundamentals filters exclude ETFs anyway./// Inputs one stock contributes to a pick ranker. A caller fills it once per/// curated stock from the standard `load_stocks` scan, then runs each/// ranker. Every signal reads off completed daily bars (closes) plus the/// rolled-up standing — no live intraday data — so a pick can never reflect/// "what already moved today".#[derive(Debug, Clone)]pub struct PickInput<'a> {    /// Daily closes, oldest first. Every ranker reads its signal off this    /// series: day = close-to-close of the last two bars, week / month =    /// trailing returns + moving averages, year = the standing (which was    /// itself computed off the same series).    pub closes: &'a [f64],    /// The Phase 20 rolled-up standing (strong / fair / weak + combined score).    /// `None` until SEC fundamentals have synced; without it the short    /// horizons skip the stock and `year` cannot rank it.    pub standing: Option<Standing>,}/// Trailing-day windows the short-horizon rankers measure their return over.const WEEK_BARS: usize = 5;const MONTH_BARS: usize = 20;/// RSI period the week ranker gates on (Phase 8's classic 14-day window).const PICK_RSI_PERIOD: usize = 14;/// RSI bands the week ranker excludes — overbought (mean reversion risk) and/// oversold (still falling, momentum not yet flipped).const PICK_RSI_HIGH: f64 = 70.0;const PICK_RSI_LOW: f64 = 30.0;/// Moving-average windows the short / medium horizons filter on. Above the/// 50-day = the stock is in a near-term uptrend; above the 200-day = it is/// not in a long-term downtrend.const PICK_SMA_SHORT: usize = 50;const PICK_SMA_LONG: usize = 200;/// 52-week-high lookback for the day ranker's "near the high" bias.const PICK_52W_BARS: usize = 252;/// Day-horizon score: the most recent **completed** daily bar's/// close-to-close return, gently boosted when the stock is near its 52-week/// high. Predicts tomorrow's continuation, not what already moved today —/// crucially the live intraday move is NOT used here, since a stock up 5%/// during the session is one you have already missed if you read this and/// buy. After market close today's bar is the most recent completed; during/// the session yesterday's is. Either way the signal is a finished day.////// `None` when the stock has weak fundamental strength (we do not pick weak/// names even on a strong day), or has fewer than two daily bars to read.pub fn pick_day(i: &PickInput<'_>) -> Option<f64> {    // Strength filter: skip the bottom band entirely; we are not picking weak    // names regardless of their price action. A stock without graded    // fundamentals (unsynced) also drops out, since we cannot vouch for it.    let s = i.standing?;    if matches!(s.grade, Grade::Bad) {        return None;    }    let len = i.closes.len();    if len < 2 {        return None;    }    let last = i.closes[len - 1];    let prev = i.closes[len - 2];    if prev <= 0.0 || last <= 0.0 {        return None;    }    let day_pct = (last - prev) / prev * 100.0;    if day_pct <= 0.0 {        return None;    }    // Near-52w-high bias: a small +pct boost for sitting close to the top of    // the trailing-year range, since a stock breaking through its own highs is    // a stronger continuation candidate than one rallying off a deep base.    // Capped so it refines rather than dominates the day's move.    let bias = if len >= PICK_52W_BARS {        let window = &i.closes[len - PICK_52W_BARS..];        let high = window.iter().copied().fold(f64::NEG_INFINITY, f64::max);        if high > 0.0 {            // 1.0 at the high, 0.0 at 10% below it, clamped.            ((last / high - 0.90) / 0.10).clamp(0.0, 1.0)        } else {            0.0        }    } else {        0.0    };    Some(day_pct + bias)}/// Week-horizon score: the trailing 5-day price return, gated on RSI not being/// extreme and the close being above its 50-day SMA. `None` when the stock has/// weak fundamental strength, fails either gate, or has too little history.////// Short-momentum read filtered by quality — the next ~week's continuation/// candidates among names already trending and not stretched.pub fn pick_week(i: &PickInput<'_>) -> Option<f64> {    let s = i.standing?;    if matches!(s.grade, Grade::Bad) {        return None;    }    if i.closes.len() <= WEEK_BARS.max(PICK_SMA_SHORT) {        return None;    }    let len = i.closes.len();    let last = i.closes[len - 1];    let start = i.closes[len - 1 - WEEK_BARS];    if start <= 0.0 || last <= 0.0 {        return None;    }    let ret_5d = (last - start) / start * 100.0;    if ret_5d <= 0.0 {        return None;    }    // Above the 50-day SMA: a near-term uptrend filter.    let sma50 = sma(i.closes, PICK_SMA_SHORT).pop().flatten()?;    if last < sma50 {        return None;    }    // RSI gate: skip stretched (>70) and falling (<30) names. The window must    // be long enough for RSI to have warmed up by the last bar.    let r = rsi(i.closes, PICK_RSI_PERIOD).pop().flatten()?;    if !(PICK_RSI_LOW..=PICK_RSI_HIGH).contains(&r) {        return None;    }    Some(ret_5d)}/// Month-horizon score: the trailing 20-day price return, gated on the close/// being above its 200-day SMA and the stock not being weak. `None` when any/// of those does not hold or history is too short.////// Medium-momentum read: stocks in a confirmed long-term uptrend that are/// also up over the last month, with fundamental quality as a floor.pub fn pick_month(i: &PickInput<'_>) -> Option<f64> {    let s = i.standing?;    if matches!(s.grade, Grade::Bad) {        return None;    }    if i.closes.len() <= MONTH_BARS.max(PICK_SMA_LONG) {        return None;    }    let len = i.closes.len();    let last = i.closes[len - 1];    let start = i.closes[len - 1 - MONTH_BARS];    if start <= 0.0 || last <= 0.0 {        return None;    }    let ret_20d = (last - start) / start * 100.0;    if ret_20d <= 0.0 {        return None;    }    let sma200 = sma(i.closes, PICK_SMA_LONG).pop().flatten()?;    if last < sma200 {        return None;    }    Some(ret_20d)}/// Trailing window the quarter ranker measures its return over: roughly one/// earnings cycle (~63 trading days).const QUARTER_BARS: usize = 63;/// Quarter-horizon score: the trailing ~63-day price return, gated on the/// close being above its 200-day SMA and the stock not being weak. `None`/// when any of those does not hold or history is too short.////// Medium-to-long momentum read: stocks in a confirmed long-term uptrend that/// have continued to grind higher across roughly one earnings cycle, with/// fundamental quality as a floor. Same shape as `pick_month` on a longer/// window — deliberately *not* a pass-through of the standing score, so the/// backtest produces genuinely different picks at different historical dates.pub fn pick_quarter(i: &PickInput<'_>) -> Option<f64> {    let s = i.standing?;    if matches!(s.grade, Grade::Bad) {        return None;    }    if i.closes.len() <= QUARTER_BARS.max(PICK_SMA_LONG) {        return None;    }    let len = i.closes.len();    let last = i.closes[len - 1];    let start = i.closes[len - 1 - QUARTER_BARS];    if start <= 0.0 || last <= 0.0 {        return None;    }    let ret_q = (last - start) / start * 100.0;    if ret_q <= 0.0 {        return None;    }    let sma200 = sma(i.closes, PICK_SMA_LONG).pop().flatten()?;    if last < sma200 {        return None;    }    Some(ret_q)}// ── Phase 16: per-ticker anomaly feed ─────────────────────────────────────/// One row in the symbol-page anomaly feed. Built either here (price events,
modified src/main.rs
@@ -5,7 +5,6 @@ mod guard;mod market;mod middleware;mod models;mod picks;mod providers;mod render;mod routes;
modified src/models.rs
@@ -110,43 +110,20 @@ pub struct FundFact {    pub fiscal_year: i64,    pub fiscal_qtr: Option<i64>,    pub value: f64,    /// `YYYY-MM-DD` end-of-period date. Carried so the backtest can pick the    /// latest annual whose figures would actually have been *filed* by an    /// as-of date (period_end + a filing-lag cushion ≤ as_of).    /// `YYYY-MM-DD` end-of-period date. Dates the fundamentals-anomaly feed    /// (the FY in which a revenue / net-income move landed).    pub period_end: String,}/// 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./// company has no annual facts. Shared by the symbol page and the home quality/// leaderboard so both grade a stock identically.pub fn latest_annual_inputs(facts: &[FundFact], price: Option<f64>) -> Option<compute::RatioInputs> {    latest_annual_inputs_filtered(facts, price, |_| true)}/// Conservative filing-lag cushion applied by [`latest_annual_inputs_as_of`]:/// an SEC 10-K is due 60–90 days after fiscal year end depending on filer/// category, so a backtest standing at date `D` should only see annuals/// whose period_end is at least 90 days before `D`. Prevents the backtest/// from grading a stock with figures that did not yet exist on its as-of date.pub const FILING_LAG_DAYS: i64 = 90;/// As-of variant of [`latest_annual_inputs`] for the backtest: pick the/// latest annual fiscal year whose `period_end + FILING_LAG_DAYS ≤ as_of`./// `None` when no annual would have been filed yet by then.pub fn latest_annual_inputs_as_of(    facts: &[FundFact],    price: Option<f64>,    as_of: chrono::NaiveDate,) -> Option<compute::RatioInputs> {    latest_annual_inputs_filtered(facts, price, |f| {        chrono::NaiveDate::parse_from_str(&f.period_end, "%Y-%m-%d")            .map(|d| d + chrono::Duration::days(FILING_LAG_DAYS) <= as_of)            .unwrap_or(false)    })}/// YoY change threshold (25%) on annual revenue or net-income above which a/// fundamentals anomaly event is emitted.const FUND_YOY_THRESHOLD: f64 = 0.25;
deleted src/picks.rs
@@ -1,755 +0,0 @@//! Top picks across four forecast horizons (Phase 30).//!//! Each horizon has a separate ranker in `compute` (`pick_day` / `pick_week`//! / `pick_month` / `pick_quarter`). This module is the glue: it loads the//! per-stock inputs once, runs every ranker, and returns the top-N per//! horizon. Two callers use it — the home page (live, every render) and the//! scheduler's `picks` snapshot job (once a day, persisted into `picks` so//! `/backtest` has immutable history to read).//!//! Stocks-only across all four horizons, per the user's design call: every//! ranker filters on Phase 20 fundamental strength, which only stocks carry.use std::collections::HashMap;use serde::Serialize;use sqlx::SqlitePool;use crate::compute::{self, PickInput, Standing};use crate::db::now_ms;use crate::models;/// How many picks each horizon keeps. User's steer: top 5.pub const PICK_LIMIT: usize = 5;/// The four horizons the picker covers, in display order. The string keys/// are what `picks.horizon` carries (stable; never change them, the picks/// table stores them); labels and descriptions are forward-looking framings/// — these are forecasts for the named period, ranked off backward-looking/// signals (recent momentum + quality).pub const HORIZONS: &[Horizon] = &[    Horizon {        key: "day",        label: "Tomorrow",        desc: "yesterday's full-day close-to-close momentum + near-52w-high; the live intraday move is intentionally not used (you'd be chasing what already happened)",    },    Horizon {        key: "week",        label: "Next week",        desc: "trailing 5-day momentum, gated on RSI not stretched and the close above SMA50",    },    Horizon {        key: "month",        label: "Next month",        desc: "trailing 20-day momentum, gated on the close above SMA200 and fundamentals not weak",    },    Horizon {        key: "quarter",        label: "Next quarter",        desc: "trailing 60-day momentum (roughly one earnings cycle), gated on the close above SMA200 and fundamentals not weak",    },];/// One forecast horizon's identity, for both the snapshot table and the UI.#[derive(Debug, Clone, Copy, Serialize)]pub struct Horizon {    pub key: &'static str,    pub label: &'static str,    /// One-line plain-English description of the horizon's signal, shown    /// quietly on the home panel so the read of a pick is not opaque.    pub desc: &'static str,}/// One ranked pick within a horizon: the ticker, its rank (1..=PICK_LIMIT),/// its raw score, and enough context for the home panel to render a row/// without a second DB pass.#[derive(Debug, Clone, Serialize)]pub struct Pick {    pub rank: u32,    pub ticker: String,    pub name: String,    /// Latest price the ranker scored against (live or the latest close).    pub price: Option<f64>,    /// The ranker's raw score. For day/week/month this is a percent return;    /// for year it is the Phase 20 score on a 100x scale, so the four lines    /// of the panel read on roughly the same magnitude.    pub score: f64,    /// The stock's rolled-up standing, for the row's verdict badge.    pub standing: Standing,}/// One horizon's slate: the horizon's identity over the ranked picks.#[derive(Debug, Clone, Serialize)]pub struct PickSlate {    pub horizon: Horizon,    pub picks: Vec<Pick>,}/// The full per-stock bundle a ranker reads. Owned (not borrowed) so the/// loader can build it once and the rankers run against it without holding/// references across an `await`.pub struct StockBundle {    pub ticker: String,    pub name: String,    /// Latest close (the tail of `closes`), kept separately for display in    /// the home panel rows.    pub last_price: Option<f64>,    /// Daily closes oldest first, up to the lookback window the rankers need    /// (longest is the 200-day SMA, with comfortable slack).    pub closes: Vec<f64>,    pub standing: Option<Standing>,}/// Trading days of daily closes the rankers need, oldest first. The 200-day/// SMA is the longest signal; an extra year of slack lets the 52-week-high/// bias and any later refinement read off the same loaded window.const LOOKBACK_DAYS: i64 = 500;/// Run all four rankers over `bundles` and return one [`PickSlate`] per/// horizon, top picks first. A stock missing inputs is silently skipped by/// the ranker; a horizon with too few qualifying stocks returns however many/// it has (down to zero).pub fn compute_picks(bundles: &[StockBundle]) -> Vec<PickSlate> {    HORIZONS        .iter()        .map(|h| {            // Score every bundle through this horizon's ranker; keep only the            // qualifying ones with a real score.            let mut scored: Vec<(&StockBundle, f64)> = bundles                .iter()                .filter_map(|b| {                    let input = PickInput {                        closes: &b.closes,                        standing: b.standing,                    };                    let score = match h.key {                        "day" => compute::pick_day(&input),                        "week" => compute::pick_week(&input),                        "month" => compute::pick_month(&input),                        "quarter" => compute::pick_quarter(&input),                        _ => None,                    }?;                    Some((b, score))                })                .collect();            // Highest score first. `partial_cmp` on a finite f64 always            // succeeds — `Some(Ordering::Equal)` on the rare tie keeps the            // sort stable.            scored.sort_by(|a, b| {                b.1.partial_cmp(&a.1)                    .unwrap_or(std::cmp::Ordering::Equal)            });            let picks = scored                .into_iter()                .take(PICK_LIMIT)                .enumerate()                .map(|(i, (b, score))| Pick {                    rank: (i + 1) as u32,                    ticker: b.ticker.clone(),                    name: b.name.clone(),                    price: b.last_price,                    score,                    // The standing is guaranteed `Some` here: every ranker                    // requires it, so a stock without one has already been                    // dropped above.                    standing: b.standing.expect("ranker required standing"),                })                .collect();            PickSlate { horizon: *h, picks }        })        .collect()}/// Load every curated-stock bundle the rankers need from the DB in three/// queries — the same shape as `routes::home::load_stocks` (Phase 20), but/// without the trailing-year cut so the long SMA / 52w-high windows are/// available. Pure read; safe to call from any caller (route render, the/// scheduler's snapshot job, the backtest replay).pub async fn load_bundles(pool: &SqlitePool) -> sqlx::Result<Vec<StockBundle>> {    // 1. Price per curated stock: live last price falling back to the latest    //    daily close, used for display alongside each pick row.    let price_rows: Vec<(String, String, 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)) \         FROM symbols s WHERE s.is_seeded = 1 AND s.kind = 'stock'",    )    .fetch_all(pool)    .await?;    if price_rows.is_empty() {        return Ok(Vec::new());    }    // 2. Every fundamentals fact for the curated stocks, grouped by ticker.    let fact_rows: Vec<(String, String, String, i64, Option<i64>, f64, String)> = sqlx::query_as(        "SELECT f.ticker, f.metric, f.period, f.fiscal_year, f.fiscal_qtr, f.value, f.period_end \         FROM fundamentals f JOIN symbols s ON s.ticker = f.ticker \         WHERE s.is_seeded = 1 AND s.kind = 'stock'",    )    .fetch_all(pool)    .await?;    let mut facts: HashMap<String, Vec<models::FundFact>> = HashMap::new();    for (ticker, metric, period, fiscal_year, fiscal_qtr, value, period_end) in fact_rows {        facts.entry(ticker).or_default().push(models::FundFact {            metric,            period,            fiscal_year,            fiscal_qtr,            value,            period_end,        });    }    // 3. Daily closes for the LOOKBACK_DAYS window, oldest first per ticker.    let cutoff = (chrono::Utc::now().date_naive() - chrono::Duration::days(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(pool)    .await?;    let mut closes: HashMap<String, Vec<f64>> = HashMap::new();    for (ticker, close) in close_rows {        closes.entry(ticker).or_default().push(close);    }    Ok(price_rows        .into_iter()        .map(|(ticker, name, last_price)| {            let stock_closes = closes.remove(&ticker).unwrap_or_default();            let standing = facts.get(&ticker).and_then(|f| {                let inputs = models::latest_annual_inputs(f, last_price)?;                compute::standing(&compute::compute_ratios(&inputs), &stock_closes)            });            StockBundle {                ticker,                name,                last_price,                closes: stock_closes,                standing,            }        })        .collect())}/// Replace `picks` for one snapshot date with the given slates. Idempotent on/// (snapshot_date, horizon, rank): re-running the snapshot for the same date/// (e.g. a manual rerun) overwrites cleanly. The whole write is a single/// transaction so a half-written snapshot is never observable by `/backtest`.pub async fn write_snapshot(    pool: &SqlitePool,    snapshot_date: &str,    slates: &[PickSlate],) -> sqlx::Result<usize> {    let mut tx = pool.begin().await?;    sqlx::query("DELETE FROM picks WHERE snapshot_date = ?")        .bind(snapshot_date)        .execute(&mut *tx)        .await?;    let mut written = 0usize;    for slate in slates {        for pick in &slate.picks {            sqlx::query(                "INSERT INTO picks \                   (snapshot_date, horizon, rank, ticker, score, price_at_pick) \                 VALUES (?, ?, ?, ?, ?, ?)",            )            .bind(snapshot_date)            .bind(slate.horizon.key)            .bind(pick.rank as i64)            .bind(&pick.ticker)            .bind(pick.score)            // The pick's entry price is whatever the ranker scored against            // (the day's close after `daily_close` runs). The backtest reads            // it back as the position's cost basis.            .bind(pick.price.unwrap_or(0.0))            .execute(&mut *tx)            .await?;            written += 1;        }    }    tx.commit().await?;    Ok(written)}/// Take a daily snapshot of every horizon's picks for `date` (an ET trading/// date, `YYYY-MM-DD`). Called by the scheduler right after `daily_close`,/// when every stock carries a fresh close.pub async fn snapshot_today(pool: &SqlitePool, date: &str) -> anyhow::Result<usize> {    let bundles = load_bundles(pool).await?;    let slates = compute_picks(&bundles);    let n = write_snapshot(pool, date, &slates).await?;    let _ = now_ms(); // touched so a future audit-log row could borrow it    Ok(n)}// ────────────────────────── backtest (Phase 30) ────────────────────────────//// Walks the picker over historical `daily_prices`, simulating "what would $X// have done if you'd followed today's algo over the past N years?". Reads// every curated stock's full close history once, then at each rebalance date// truncates each stock's closes to that point and runs the horizon's ranker.// The picks held for one stride, equal-weight, are sold at the next// rebalance close.//// Out-of-sample: at each rebalance date the picker grades a stock only// against fundamentals that would actually have been filed by then// (`models::latest_annual_inputs_as_of`, with a 90-day filing-lag cushion)// and only against closes up to that date. So a stock that grades strong// today but was weak in 2022 will grade weak in a 2022 rebalance./// One historical bar a backtest reads off — a trading date and the close.#[derive(Debug, Clone)]pub struct HistBar {    pub date: String,    pub close: f64,}/// Per-stock bundle for the backtest: the full close history the rankers/// walk over plus the raw stored fundamentals. The standing is *not*/// precomputed; `rank_at` builds it at each rebalance date from the slice/// of closes-so-far and the latest annual that would actually have been/// filed by then (see [`models::latest_annual_inputs_as_of`]). That makes/// the backtest genuinely out-of-sample: a stock that is strong today but/// was weak in 2022 won't be graded "strong" in a 2022 rebalance.pub struct HistBundle {    pub ticker: String,    pub bars: Vec<HistBar>,    pub facts: Vec<models::FundFact>,}/// The benchmark every horizon's strategy is measured against. Hardcoded to/// `^SPX` — the broad-market index our universe is built around — so the/// backtest's "did you beat the market?" read is consistent across horizons.pub const BENCHMARK_TICKER: &str = "^SPX";/// Calendar days of history the backtest's loader pulls. Comfortably covers/// the longest horizon (quarter, ~20 rebalances × 63 bars ≈ 5 years) plus/// the warm-up the rankers need (200-day SMA + a year for the standing)./// Capping here keeps the load query off the deep history some indexes carry/// (`^SPX` goes back to 1789), trading no real backtest depth for a fast/// request.const HIST_LOOKBACK_DAYS: i64 = 365 * 7;/// One pick within a backtest period — its entry and exit prices and the/// resulting return.#[derive(Debug, Clone, Serialize)]pub struct BacktestPick {    pub ticker: String,    pub entry_price: f64,    pub exit_price: f64,    /// Percent return over the period this pick was held.    pub return_pct: f64,}/// One rebalance period of the backtest: the picks held, the basket's/// equal-weight return, and how the benchmark fared over the same dates.#[derive(Debug, Clone, Serialize)]pub struct BacktestPeriod {    pub start_date: String,    pub end_date: String,    pub picks: Vec<BacktestPick>,    pub basket_return_pct: f64,    pub benchmark_return_pct: f64,    pub beat_benchmark: bool,}/// One point on the equity curve: the strategy's running $-value and the/// benchmark's running $-value (both starting at the requested capital).#[derive(Debug, Clone, Serialize)]pub struct EquityPoint {    pub date: String,    pub strategy: f64,    pub benchmark: f64,}/// Summary stats the backtest page surfaces alongside the chart.#[derive(Debug, Clone, Serialize)]pub struct BacktestStats {    pub final_strategy: f64,    pub final_benchmark: f64,    pub total_return_pct: f64,    pub benchmark_return_pct: f64,    /// Annualised. Equals `total_return_pct` for windows of a year or less.    pub cagr_pct: f64,    pub benchmark_cagr_pct: f64,    /// Fraction of individual picks that closed up over their hold period.    pub per_pick_win_rate: f64,    /// Fraction of periods where the basket beat the benchmark.    pub per_period_win_rate: f64,    pub num_periods: u32,    pub num_picks: u32,    pub period_start: String,    pub period_end: String,}/// One horizon's full backtest result — equity curve, period-by-period/// detail, summary stats — for the page's JSON feed.#[derive(Debug, Clone, Serialize)]pub struct BacktestResult {    pub horizon: Horizon,    pub starting_capital: f64,    pub bench_ticker: &'static str,    pub equity: Vec<EquityPoint>,    pub periods: Vec<BacktestPeriod>,    pub stats: Option<BacktestStats>,}/// Trading-day stride per horizon (one rebalance per stride). Day = daily/// rebalance; year = once a year. Calendar days happen to converge: 252/// trading days ≈ 1 year.fn stride_for(horizon_key: &str) -> usize {    match horizon_key {        "day" => 1,        "week" => 5,        "month" => 20,        "quarter" => 63,        _ => 1,    }}/// Maximum backtest window per horizon, in trading bars. Cap each so the/// number of rebalances stays meaningful without dragging the request: day/// has too many otherwise, quarter too few on a short window. Roughly/// 1y / 2y / 4y / 5y across the four horizons.fn max_bars_for(horizon_key: &str) -> usize {    match horizon_key {        "day" => 252,        "week" => 504,        "month" => 1008,        "quarter" => 1260,        _ => 252,    }}/// Load every curated stock's full close history plus the benchmark's. Two/// queries. Returns the bundles and the benchmark's bars, separately so the/// caller can index them independently. The bundles' `standing` is today's/// value, computed off today's fundamentals and the full history (the/// acknowledged look-ahead).pub async fn load_hist_bundles(    pool: &SqlitePool,) -> sqlx::Result<(Vec<HistBundle>, Vec<HistBar>)> {    // 1. The curated stocks' tickers. (Price not needed: the backtest only    //    reads each stock's closes, indexed by date, never `last_price`.    //    Name not needed either: the backtest table shows tickers only.)    let price_rows: Vec<(String,)> = sqlx::query_as(        "SELECT s.ticker FROM symbols s WHERE s.is_seeded = 1 AND s.kind = 'stock'",    )    .fetch_all(pool)    .await?;    let fact_rows: Vec<(String, String, String, i64, Option<i64>, f64, String)> = sqlx::query_as(        "SELECT f.ticker, f.metric, f.period, f.fiscal_year, f.fiscal_qtr, f.value, f.period_end \         FROM fundamentals f JOIN symbols s ON s.ticker = f.ticker \         WHERE s.is_seeded = 1 AND s.kind = 'stock'",    )    .fetch_all(pool)    .await?;    let mut facts: HashMap<String, Vec<models::FundFact>> = HashMap::new();    for (ticker, metric, period, fiscal_year, fiscal_qtr, value, period_end) in fact_rows {        facts.entry(ticker).or_default().push(models::FundFact {            metric,            period,            fiscal_year,            fiscal_qtr,            value,            period_end,        });    }    // 2. Daily closes for the curated stocks, oldest first per ticker. Capped    //    at `HIST_LOOKBACK_DAYS` of calendar history so a deep universe (some    //    indexes go back to the 1800s) does not pull a million-row scan; the    //    backtest's longest horizon (year, 252-bar stride × ~5 rebalances)    //    fits comfortably inside this window.    let cutoff = (chrono::Utc::now().date_naive()        - chrono::Duration::days(HIST_LOOKBACK_DAYS))    .to_string();    let close_rows: Vec<(String, String, f64)> = sqlx::query_as(        "SELECT p.ticker, p.d, 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(pool)    .await?;    let mut bars_by: HashMap<String, Vec<HistBar>> = HashMap::new();    for (ticker, date, close) in close_rows {        bars_by.entry(ticker).or_default().push(HistBar { date, close });    }    let bundles: Vec<HistBundle> = price_rows        .into_iter()        .map(|(ticker,)| {            let bars = bars_by.remove(&ticker).unwrap_or_default();            let facts = facts.remove(&ticker).unwrap_or_default();            HistBundle { ticker, bars, facts }        })        .collect();    // 3. The benchmark's history over the same window. It anchors the    //    timeline: rebalance dates come from its bar list, since every    //    stock's price is sampled at-or-before those dates.    let bench_rows: Vec<(String, f64)> = sqlx::query_as(        "SELECT d, close FROM daily_prices WHERE ticker = ? AND d >= ? ORDER BY d",    )    .bind(BENCHMARK_TICKER)    .bind(&cutoff)    .fetch_all(pool)    .await?;    let bench_bars: Vec<HistBar> = bench_rows        .into_iter()        .map(|(date, close)| HistBar { date, close })        .collect();    Ok((bundles, bench_bars))}/// Rank `bundles` as of the trading date `as_of`. Each bundle is sliced to/// its own bars at-or-before that date; its standing is computed *as of*/// that date too, using the latest annual that would actually have been/// filed by then (see [`models::latest_annual_inputs_as_of`]) — so a stock/// that is strong today but was weak in 2022 will grade weak in a 2022/// rebalance. A stock that does not yet have two bars, or whose/// fundamentals had not been filed yet, is silently dropped.fn rank_at(    bundles: &[HistBundle],    as_of: &str,    horizon_key: &str,) -> Vec<(String, f64, f64)> {    let as_of_date = chrono::NaiveDate::parse_from_str(as_of, "%Y-%m-%d").ok();    let mut scored: Vec<(String, f64, f64)> = bundles        .iter()        .filter_map(|b| {            let as_of_date = as_of_date?;            // Find the index of the bundle's last bar at-or-before `as_of`.            // `partition_point` returns the first index *after* the predicate            // holds, so subtracting one yields the bar at-or-before. The            // bars are date-string sorted (YYYY-MM-DD compares correctly).            let upper = b.bars.partition_point(|x| x.date.as_str() <= as_of);            if upper < 2 {                return None;            }            let idx = upper - 1;            let last_price = b.bars[idx].close;            let closes: Vec<f64> = b.bars[..=idx].iter().map(|x| x.close).collect();            // Standing as-of: grade the latest annual whose period_end is            // at least FILING_LAG_DAYS before as_of, against the closes            // sliced to as_of (so the trajectory score reads the same            // window the rankers see).            let standing = models::latest_annual_inputs_as_of(                &b.facts,                Some(last_price),                as_of_date,            )            .and_then(|inputs| {                compute::standing(&compute::compute_ratios(&inputs), &closes)            });            let input = PickInput {                closes: &closes,                standing,            };            let score = match horizon_key {                "day" => compute::pick_day(&input),                "week" => compute::pick_week(&input),                "month" => compute::pick_month(&input),                "quarter" => compute::pick_quarter(&input),                _ => None,            }?;            Some((b.ticker.clone(), score, last_price))        })        .collect();    scored.sort_by(|a, b| {        b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)    });    scored.truncate(PICK_LIMIT);    scored}/// Find the close at-or-before `date` in a bundle's bars. The bars are/// date-string sorted (YYYY-MM-DD compares lexicographically), so a binary-/// search partition_point is exact.fn close_at_or_before(bars: &[HistBar], date: &str) -> Option<f64> {    let upper = bars.partition_point(|x| x.date.as_str() <= date);    (upper > 0).then(|| bars[upper - 1].close).filter(|c| *c > 0.0)}/// Run one horizon's backtest over the data already loaded. Walks back from/// the benchmark's last bar by `stride` until either `max_bars` is exhausted/// or the start of history is reached, then runs the periods forward in/// time so the equity curve is chronological. `starting_capital` is the/// strategy's and the benchmark's initial $-value.pub fn run_backtest(    bundles: &[HistBundle],    bench: &[HistBar],    horizon: Horizon,    starting_capital: f64,) -> BacktestResult {    let stride = stride_for(horizon.key);    let max_bars = max_bars_for(horizon.key);    let empty = || BacktestResult {        horizon,        starting_capital,        bench_ticker: BENCHMARK_TICKER,        equity: Vec::new(),        periods: Vec::new(),        stats: None,    };    if bench.len() < stride * 2 + 1 {        return empty();    }    // Rebalance points: indices into `bench`, oldest first, spaced `stride`    // apart. The newest possible point is one stride from the end (so we    // have a "hold for stride days" window past it).    let end_idx = bench.len() - 1;    let last_rebal = end_idx - stride;    let earliest_rebal = bench.len().saturating_sub(max_bars).max(stride);    if earliest_rebal > last_rebal {        return empty();    }    let mut rebal_idxs: Vec<usize> = Vec::new();    let mut i = last_rebal;    while i >= earliest_rebal {        rebal_idxs.push(i);        if i < stride {            break;        }        i -= stride;    }    rebal_idxs.reverse();    let mut periods: Vec<BacktestPeriod> = Vec::new();    let mut equity: Vec<EquityPoint> = Vec::new();    let mut strat_val = starting_capital;    let mut bench_val = starting_capital;    // The equity curve starts at the first rebalance's date. Anchoring it    // there keeps the strategy and benchmark series aligned.    equity.push(EquityPoint {        date: bench[rebal_idxs[0]].date.clone(),        strategy: strat_val,        benchmark: bench_val,    });    for win in rebal_idxs.windows(2) {        let (start_idx, end_idx) = (win[0], win[1]);        let start_date = bench[start_idx].date.as_str();        let end_date = bench[end_idx].date.as_str();        let bench_start = bench[start_idx].close;        let bench_end = bench[end_idx].close;        if bench_start <= 0.0 {            continue;        }        let bench_ret_pct = (bench_end - bench_start) / bench_start * 100.0;        // The picks the horizon's ranker would have named at `start_date`,        // entry prices captured from the rank, exit prices from each        // stock's at-or-before-`end_date` close.        let ranked = rank_at(bundles, start_date, horizon.key);        let picks: Vec<BacktestPick> = ranked            .into_iter()            .filter_map(|(ticker, _score, entry)| {                let bundle = bundles.iter().find(|b| b.ticker == ticker)?;                let exit = close_at_or_before(&bundle.bars, end_date)?;                if entry <= 0.0 {                    return None;                }                let ret = (exit - entry) / entry * 100.0;                Some(BacktestPick {                    ticker,                    entry_price: entry,                    exit_price: exit,                    return_pct: ret,                })            })            .collect();        // Skip a period where nothing qualified — the strategy is "all cash"        // that period, which is not what the backtest is meant to show. The        // benchmark also does not advance, so equity simply pauses.        if picks.is_empty() {            continue;        }        let basket_ret_pct = picks.iter().map(|p| p.return_pct).sum::<f64>()            / picks.len() as f64;        strat_val *= 1.0 + basket_ret_pct / 100.0;        bench_val *= 1.0 + bench_ret_pct / 100.0;        periods.push(BacktestPeriod {            start_date: start_date.to_string(),            end_date: end_date.to_string(),            picks,            basket_return_pct: basket_ret_pct,            benchmark_return_pct: bench_ret_pct,            beat_benchmark: basket_ret_pct > bench_ret_pct,        });        equity.push(EquityPoint {            date: end_date.to_string(),            strategy: strat_val,            benchmark: bench_val,        });    }    let stats = if let (Some(first), Some(last)) = (periods.first(), periods.last()) {        let total = (strat_val / starting_capital - 1.0) * 100.0;        let bench_total = (bench_val / starting_capital - 1.0) * 100.0;        let num_picks: u32 = periods.iter().map(|p| p.picks.len() as u32).sum();        let per_pick_wins: u32 = periods            .iter()            .flat_map(|p| p.picks.iter())            .filter(|p| p.return_pct > 0.0)            .count() as u32;        let per_period_wins =            periods.iter().filter(|p| p.beat_benchmark).count() as u32;        // Years between the first period's start and the last period's end —        // by actual calendar days / 365.25, so a 2.5-year backtest annualises        // correctly.        let years = chrono::NaiveDate::parse_from_str(&last.end_date, "%Y-%m-%d")            .ok()            .zip(chrono::NaiveDate::parse_from_str(&first.start_date, "%Y-%m-%d").ok())            .map(|(a, b)| (a - b).num_days() as f64 / 365.25)            .unwrap_or(1.0)            .max(1.0 / 12.0);        let cagr = if years > 1.0 {            ((strat_val / starting_capital).powf(1.0 / years) - 1.0) * 100.0        } else {            total        };        let bench_cagr = if years > 1.0 {            ((bench_val / starting_capital).powf(1.0 / years) - 1.0) * 100.0        } else {            bench_total        };        Some(BacktestStats {            final_strategy: strat_val,            final_benchmark: bench_val,            total_return_pct: total,            benchmark_return_pct: bench_total,            cagr_pct: cagr,            benchmark_cagr_pct: bench_cagr,            per_pick_win_rate: per_pick_wins as f64 / num_picks.max(1) as f64 * 100.0,            per_period_win_rate: per_period_wins as f64 / periods.len().max(1) as f64                * 100.0,            num_periods: periods.len() as u32,            num_picks,            period_start: first.start_date.clone(),            period_end: last.end_date.clone(),        })    } else {        None    };    BacktestResult {        horizon,        starting_capital,        bench_ticker: BENCHMARK_TICKER,        equity,        periods,        stats,    }}
deleted src/routes/backtest.rs
@@ -1,91 +0,0 @@//! `/backtest` — stress-test the picker (Phase 30).//!//! Replays the current pick rankers over historical `daily_prices` and shows//! "what would $X have done if you'd followed today's algo over the past//! few years?". Page is a small chart shell; the heavy lifting lives in//! `picks::run_backtest`, served as JSON at//! `GET /api/backtest?horizon=<key>&capital=<usd>`.//!//! At each historical rebalance date the picker grades a stock using only the//! fundamentals that would actually have been *filed* by then (latest annual//! whose period_end is at least 90 days before the rebalance — see//! `models::FILING_LAG_DAYS`) and only the closes up to that date, so the//! backtest is genuinely out-of-sample: a stock that grades strong today//! but was weak in 2022 will grade weak in a 2022 rebalance.use axum::{    extract::{Query, State},    http::StatusCode,    response::{IntoResponse, Response},    routing::get,    Json, Router,};use serde::Deserialize;use serde_json::json;use crate::picks::{self, HORIZONS};use crate::render::render;use crate::AppState;pub fn router() -> Router<AppState> {    Router::new()        .route("/backtest", get(backtest_page))        .route("/api/backtest", get(backtest_api))}async fn backtest_page(State(state): State<AppState>) -> Response {    let extra = minijinja::context! {        title => "Backtest",        horizons => HORIZONS,    };    render(&state, "pages/backtest.html", "/backtest", extra)}#[derive(Debug, Deserialize)]struct BacktestQuery {    /// One of `day | week | month | quarter`. Defaults to `month`, the    /// medium-cadence read that has both enough rebalances to be informative    /// and few enough to be quick to glance at.    horizon: Option<String>,    /// Starting capital in USD; defaults to $10,000 (the same anchor as the    /// Phase 28 growth-of-$10k chart).    capital: Option<f64>,}/// Run the requested horizon's backtest and return its full result as JSON./// One heavy DB scan per request (the curated stocks' full close history);/// not cached, since the data turns over once a day and the page is/// operator-facing.async fn backtest_api(    State(state): State<AppState>,    Query(q): Query<BacktestQuery>,) -> Response {    let key = q.horizon.unwrap_or_else(|| "month".to_string());    // Match by key against the static HORIZONS list so the JSON carries the    // canonical horizon metadata (label + description) instead of just an    // echoed query string.    let Some(horizon) = HORIZONS.iter().copied().find(|h| h.key == key) else {        return (            StatusCode::BAD_REQUEST,            Json(json!({"error": "unknown horizon"})),        )            .into_response();    };    // Clamp the capital below at $1: a zero or negative starting capital    // makes the equity curve meaningless and the CAGR explode.    let capital = q.capital.filter(|c| *c >= 1.0).unwrap_or(10_000.0);    let (bundles, bench) = match picks::load_hist_bundles(&state.pool).await {        Ok(pair) => pair,        Err(e) => {            tracing::error!("backtest load: {e}");            return (                StatusCode::INTERNAL_SERVER_ERROR,                Json(json!({"error": "load failed"})),            )                .into_response();        }    };    let result = picks::run_backtest(&bundles, &bench, horizon, capital);    Json(result).into_response()}
modified src/routes/home.rs
@@ -16,7 +16,6 @@ use serde::Serialize;use crate::compute::{self, Sparkline};use crate::market;use crate::models;use crate::picks;use crate::render::render;use crate::AppState;
@@ -51,12 +50,8 @@ const COMMODITIES: &[&str] = &["^VIX", "CL=F", "GC=F", "NG=F"];/// How many gainers and how many losers each movers panel lists.const MOVERS_LIMIT: usize = 8;/// 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;/// How many stocks each of the healthiest / most-concerning health panels/// lists (Phase 17). Mirrors `STANDING_LIMIT`./// How many stocks each side of the quality leaderboard (healthiest //// most-concerning) lists. Mirrors `MOVERS_LIMIT` so the panels read alike.const HEALTH_LIMIT: usize = 8;/// A symbol's latest session counts as the bars within this window of its most
@@ -109,33 +104,21 @@ struct Mover {    strength: Option<compute::Standing>,}/// One row in a strongest / weakest panel./// One row in the quality leaderboard (healthiest / most-concerning panels,/// Phase 17 / reframed in Phase 3). Carries the `HealthRead` directly so the/// row can show the overall verdict alongside the three sub-readings, plus the/// trailing-year return as a quiet price-performance anchor (folded in from the/// old strongest / weakest panel when those merged into the leaderboard).#[derive(Serialize, Clone)]struct StandingRow {struct HealthRow {    ticker: String,    name: String,    /// The combined fundamentals-and-trajectory standing this row is ranked by.    standing: compute::Standing,    health: compute::HealthRead,    /// 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 row in a healthiest / most-concerning panel (Phase 17). Carries the/// `HealthRead` directly so the row can show the overall verdict alongside/// the three sub-readings.#[derive(Serialize, Clone)]struct HealthRow {    ticker: String,    name: String,    health: compute::HealthRead,    /// Width (0..100) of the row's magnitude tint, scaled to the largest    /// absolute score shown across both panels (mirrors movers / standing).    bar: f64,    /// Colour hook: true when the composite score is not negative.    up: bool,}
@@ -203,11 +186,10 @@ 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 both the movers and the strongest /    // weakest panels.    // One scan of the curated stocks feeds the movers, the industry composites,    // and the quality leaderboard.    let stocks = load_stocks(&state).await;    let (gainers, losers) = movers(&stocks);    let (strongest, weakest) = strength_panels(&stocks);    let (healthiest, concerning) = health_panels(&stocks);    let (top_industries, bottom_industries) = industry_panels(&stocks);    let industries_asof = stocks
@@ -215,22 +197,10 @@ async fn home(State(state): State<AppState>) -> Response {        .filter_map(|s| s.asset_profile_synced_at)        .max();    // Top picks (Phase 30): computed live every render, so the panel works on    // day 1 before the snapshot job has run. A separate scan (LOOKBACK_DAYS is    // longer than `load_stocks` needs for the 12m return, since the picks    // rankers want the full 200-day SMA + 52-week-high window). Cheap; the    // home render still comes in well under 1s warm.    let pick_slates = picks::load_bundles(&state.pool)        .await        .map(|b| picks::compute_picks(&b))        .unwrap_or_default();    // Section freshness (PLAN.md Phase 22): the movers panels date off the    // freshest stock quote, the strongest / weakest panels off the most recent    // SEC fundamentals sync — the data each panel actually leans on.    // freshest stock quote — the data each panel actually leans on.    let movers_asof = stocks.iter().filter_map(|s| s.last_quote_at).max();    let standings_asof = stocks.iter().filter_map(|s| s.fundamentals_synced_at).max();    // The health panels lean on both fundamentals and leadership, so their    // The quality leaderboard leans on both fundamentals and leadership, so its    // freshness caption tracks whichever sync ran later.    let health_asof = stocks        .iter()
@@ -252,13 +222,9 @@ async fn home(State(state): State<AppState>) -> Response {        gainers => gainers,        losers => losers,        movers_asof => movers_asof,        strongest => strongest,        weakest => weakest,        standings_asof => standings_asof,        healthiest => healthiest,        concerning => concerning,        health_asof => health_asof,        pick_slates => pick_slates,        top_industries => top_industries,        bottom_industries => bottom_industries,        industries_asof => industries_asof,
@@ -639,65 +605,14 @@ fn movers(stocks: &[StockRow]) -> (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)}/// The healthiest and most-concerning curated stocks by their Phase 17/// composite — fundamentals + trajectory + leadership stability. A broader/// read than the Phase 20 strongest / weakest pair, layering the leadership/// stability signal on top. Stocks without a health read (fundamentals not/// synced) are left out; the panels are a fixed page-load snapshot./// The quality leaderboard: the healthiest and most-concerning curated stocks/// by their Phase 17 composite — fundamentals + recent trajectory + leadership/// stability rolled into one read. The single non-advice "best / worst quality/// right now" surface that replaced the old Top picks, Strongest & weakest, and/// Stock health panels (Phase 3). Each row also carries its trailing-year/// return as a price-performance anchor. Stocks without a health read/// (fundamentals not synced) are left out; the panels are a fixed page-load/// snapshot.fn health_panels(stocks: &[StockRow]) -> (Vec<HealthRow>, Vec<HealthRow>) {    let mut ranked: Vec<&StockRow> = stocks.iter().filter(|s| s.health.is_some()).collect();    if ranked.is_empty() {
@@ -714,6 +629,7 @@ fn health_panels(stocks: &[StockRow]) -> (Vec<HealthRow>, Vec<HealthRow>) {            ticker: s.ticker.clone(),            name: s.name.clone(),            health,            ret_12m: s.ret_12m,            bar: 0.0,            up: health.score >= 0.0,        }
modified src/routes/industries.rs
@@ -36,8 +36,7 @@ use crate::AppState;/// Trading-day cap on the composite price chart. Five years is long enough/// to span a full market cycle, short enough that the equal-weight scan over/// dozens of members is cheap; matches the deep-history cap in/// `routes::backtest`'s `HIST_LOOKBACK_DAYS`./// dozens of members is cheap.const COMPOSITE_LOOKBACK_DAYS: i64 = 5 * 365 + 2;/// One row per stock with classification + the figures the page needs.
modified src/routes/mod.rs
@@ -1,4 +1,3 @@pub mod backtest;pub mod health;pub mod home;pub mod industries;
modified src/scheduler.rs
@@ -40,7 +40,7 @@ use crate::providers::{    PortfolioData, Quote, QuoteProvider,};use crate::stream::{Hub, QuoteUpdate, StreamEvent};use crate::{picks, seed, Config};use crate::{seed, Config};/// How often the loop wakes to check whether a job is due. The jobs themselves/// run hours apart; a one-minute tick is plenty responsive and nearly free
@@ -320,15 +320,6 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle                tracing::warn!("[scheduler] daily close: {e:#}");            }            // Top picks snapshot (Phase 30): immediately after the daily close            // runs, freeze each horizon's 5 picks into the `picks` table. Same            // once-per-ET-day cadence — keyed in `meta` so it does not repeat,            // and gated on `daily_close_date` already being set so the picks            // are scored off fresh closes, not a stale half-snapshot.            if let Err(e) = run_picks_snapshot_if_due(&pool, &hub).await {                tracing::warn!("[scheduler] picks snapshot: {e:#}");            }            if let Err(e) = run_prune_if_due(&pool, &mut last_prune, &hub).await {                tracing::warn!("[scheduler] prune: {e:#}");            }
@@ -840,54 +831,6 @@ async fn run_daily_close_if_due(    Ok(())}/// Top picks snapshot (Phase 30).////// Once a day, right after `run_daily_close_if_due` populates fresh closes,/// run every horizon's ranker and persist the top-5 picks to the `picks`/// table. The snapshot is what makes `/backtest` honest: an algo tweak the/// next day cannot rewrite history, because the picks for every past day are/// already frozen.////// Keyed in `meta` so a single snapshot per ET trading date — and gated on/// `daily_close_date` being set, so we never freeze picks scored against/// stale prices.async fn run_picks_snapshot_if_due(pool: &SqlitePool, hub: &Hub) -> anyhow::Result<()> {    // Today's daily close must have already landed: the picks are scored off    // the close, so running before it would freeze yesterday's snapshot.    let close_date = get_meta(pool, "daily_close_date").await?;    let Some(date) = close_date else {        return Ok(());    };    if get_meta(pool, "picks_snapshot_date").await?.as_deref() == Some(date.as_str()) {        return Ok(());    }    let started = now_ms();    mark_fetching(pool, "picks").await?;    notify_health(hub);    let t0 = Instant::now();    let written = picks::snapshot_today(pool, &date).await;    let dur = t0.elapsed().as_millis() as i64;    match written {        Ok(n) => {            set_meta(pool, "picks_snapshot_date", &date).await?;            let detail = format!("{n} picks for {date}");            tracing::info!("[scheduler] picks: {detail}");            log_fetch(pool, "picks", "-", "ok", Some(&detail), Some(n as i64), dur, started)                .await?;            mark_ok(pool, "picks", None).await?;        }        Err(e) => {            let msg = format!("{e:#}");            log_fetch(pool, "picks", "-", "error", Some(&msg), None, dur, started).await?;            mark_error(pool, "picks", &msg, None).await?;        }    }    notify_health(hub);    Ok(())}/// SEC fundamentals, filings & ETF fund-profile sweep.////// On the first run (and whenever new symbols appear) two bulk ticker-map
modified templates/includes/macros.html
@@ -67,61 +67,12 @@</a>{% endmacro %}{# `picks_column` renders one horizon's column of Top picks (Phase 30):   a header with the horizon's label + a quiet one-line description, then up   to 5 ranked rows. The score is formatted differently per horizon — a   percent return for day/week/month, a raw composite for year — so each   row's headline figure reads in the right units. `slate.picks` is empty   when no stock qualified (e.g. no fundamentals synced yet); the panel   gracefully shows an empty state instead of disappearing. #}{% macro picks_column(slate) %}<section class="picks-col">  <h3 class="picks-col__title">{{ slate.horizon.label }}</h3>  <p class="picks-col__desc">{{ slate.horizon.desc }}</p>  {% if slate.picks %}  <ol class="picks-col__list">    {% for p in slate.picks %}    <li class="pick">      <a class="pick__row" href="/s/{{ p.ticker|urlencode }}">        <span class="pick__rank num">{{ p.rank }}</span>        <span class="pick__sym num">{{ p.ticker }}</span>        <span class="pick__name">{{ p.name }}</span>        <span class="pick__badge">{{ verdict_badge(p.standing) }}</span>        <span class="pick__score num {{ 'is-up' if p.score >= 0 else 'is-down' }}">          {%- if slate.horizon.key == 'year' -%}            {{ '%+.1f'|format(p.score) }}          {%- else -%}            {{ p.score|pct }}          {%- endif -%}        </span>      </a>    </li>    {% endfor %}  </ol>  {% else %}  <p class="picks-col__empty">No qualifying picks &mdash; fundamentals may not be synced yet.</p>  {% endif %}</section>{% 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 %}{# `health_row` renders one healthiest / most-concerning row (Phase 17). The   overall verdict badge sits on the right; the three sub-readings ride below   the name as a compact line so the synthesis is legible without expanding.   `--bar` sizes the magnitude tint exactly like the standing rows. #}{# `health_row` renders one quality-leaderboard row (Phase 17, reframed in   Phase 3). The overall verdict badge sits on the right; the three sub-readings   ride below the name as a compact line so the synthesis is legible without   expanding, and the trailing-year return sits between as a quiet price anchor.   `--bar` sizes the magnitude tint, scaled by the route to the largest score   shown. #}{% macro health_row(r) %}<a class="hrow {{ 'hrow--up' if r.up else 'hrow--down' }}"   href="/s/{{ r.ticker|urlencode }}" style="--bar: {{ r.bar }}%">
@@ -134,6 +85,7 @@      <span class="hrow__chip hrow__chip--{{ r.health.stability }}">{{ r.health.stability_label }}</span>    </span>  </span>  <span class="hrow__ret num">{% if r.ret_12m is not none %}{{ r.ret_12m|pct }}{% endif %}</span>  {{ verdict_badge({"grade": r.health.overall, "verdict": r.health.verdict}) }}</a>{% endmacro %}
deleted templates/pages/backtest.html
@@ -1,51 +0,0 @@{% extends "base.html" %}{% block title %}Backtest{% endblock %}{% block extra_css %}<link rel="stylesheet" href="{{ vite_asset('static_src/backtest/index.js', 'css') }}">{% endblock %}{% block main %}<div class="wrap">  <div class="page-head">    <h1>Backtest</h1>    <a class="page-head__link" href="/">Back to home</a>  </div>  <p class="bt-blurb">Walks the picker over historical daily prices,     rebalancing equal-weight into its top 5 picks every horizon, and     compares the simulated capital to <code>^SPX</code> over the same window.</p>  <p class="disclaimer">For fun and testing, not financial advice. The picker     grades a stock only against fundamentals filed at least 90 days before     each rebalance and against closes up to that date, so the result is     genuinely out-of-sample. Frictionless: no costs, slippage, or taxes     modelled.</p>  {# Horizon tabs: clicking re-runs the backtest in place via the JSON     endpoint, so the URL stays /backtest while the rendered horizon     changes. The active tab is set by the JS once it knows what it loaded. #}  <div class="bt-tabs" role="tablist">    {% for h in horizons %}    <button class="bt-tab" data-horizon="{{ h.key }}" role="tab"            type="button" aria-controls="bt-panel">{{ h.label }}</button>    {% endfor %}  </div>  <div id="bt-panel" class="bt-panel" role="tabpanel">    {# A bone skeleton while the JSON endpoint loads, so the page does not       collapse into bare "Loading…" text. Replaced in place by the JS once       the snapshot arrives. #}    <div class="bt-skeleton" data-role="skeleton">      <div class="bt-skeleton__stats">        <div class="bt-skeleton__stat"></div>        <div class="bt-skeleton__stat"></div>        <div class="bt-skeleton__stat"></div>        <div class="bt-skeleton__stat"></div>      </div>      <div class="bt-skeleton__chart"></div>      <p class="bt-status">Loading backtest&hellip;</p>    </div>  </div></div>{% endblock %}{% block extra_js %}<script type="module" src="{{ vite_asset('static_src/backtest/index.js') }}"></script>{% endblock %}
modified templates/pages/home.html
@@ -15,7 +15,7 @@       starter list and its deep price history. This dashboard fills in once data lands.</p>  </section>  {% else %}  {% from "includes/macros.html" import spark_card, mover_row, standing_row, health_row, picks_column %}  {% from "includes/macros.html" import spark_card, mover_row, health_row %}  <div class="page-head">    <h1>Markets</h1>
@@ -56,20 +56,6 @@    </section>  </div>  <h2 class="section-title">Top picks<span class="section-title__asof"><a href="/backtest">Backtest these &rarr;</a></span></h2>  <p class="section-note">Forecasts for tomorrow, next week, next month and     next quarter &mdash; ranked off recent momentum and the fundamentals /     trajectory the app already carries, using only data available now.     The figure beside each row is the signal that earned the pick (e.g.     yesterday&rsquo;s move, trailing 20-day return), not a prediction of     what it will return.</p>  <p class="disclaimer">For fun and testing. Not financial advice. The picker     uses momentum + quality as forecasting signals; it does not know     what tomorrow holds.</p>  <div class="picks-grid">    {% for slate in pick_slates %}{{ picks_column(slate) }}{% endfor %}  </div>  <h2 class="section-title">Today&rsquo;s industries{% if industries_asof %}<span class="section-title__asof">classifications synced {{ industries_asof|ago }}</span>{% endif %}<span class="section-title__asof"><a href="/industries">All industries &rarr;</a></span></h2>  <p class="section-note">Equal-weight day move of the curated stocks inside     each sector. The classification comes from Yahoo&rsquo;s
@@ -112,12 +98,14 @@    </section>  </div>  <h2 class="section-title">Stock health{% if health_asof %}<span class="section-title__asof">last sync {{ health_asof|ago }}</span>{% endif %}</h2>  <p class="section-note">Fundamentals + recent price and growth trajectory +     leadership stability, rolled into one read per stock. Industry context     is not yet folded in.</p>  <p class="disclaimer">For fun and reading at a glance. Not investment advice     and not a buy or sell signal.</p>  <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.     The figure beside each row is its trailing-year return. Ranks the     healthiest names against the most concerning right now &mdash; it is not a     forecast of what any of them will do next.</p>  <p class="disclaimer">For reading at a glance. Not investment advice and not a     buy or sell signal.</p>  <div class="movers">    <section class="movers__panel">      <h3 class="movers__title">Healthiest</h3>
@@ -136,29 +124,6 @@      {% endif %}    </section>  </div>  <h2 class="section-title">Strongest &amp; weakest{% if standings_asof %}<span class="section-title__asof">fundamentals synced {{ standings_asof|ago }}</span>{% endif %}</h2>  <p class="section-note">A broader read than the day&rsquo;s move: each curated     stock&rsquo;s fundamentals graded and rolled up, weighted with its price and     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 %}