@@ -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…</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)} → ${escape(s.period_end)} · ${s.num_periods} rebalances · ${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)} → <strong>$${fmtUsd(s.final_strategy)}</strong> · ${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)} → <strong>$${fmtUsd(s.final_benchmark)}</strong> · ${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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[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';
@@ -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())
@@ -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,
@@ -5,7 +5,6 @@ mod guard;mod market;mod middleware;mod models;mod picks;mod providers;mod render;mod routes;
@@ -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;
@@ -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 — 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…</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 →</a></span></h2> <p class="section-note">Forecasts for tomorrow, next week, next month and next quarter — 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’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’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 →</a></span></h2> <p class="section-note">Equal-weight day move of the curated stocks inside each sector. The classification comes from Yahoo’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 — 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 & 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’s move: each curated stock’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 %}