@@ -30,9 +30,24 @@ and resume cleanly from this file alone, keeping token use low.## Status_Last updated: 2026-05-22_**Current phase: none in progress. Phase 28 (ETFs as first-class_Last updated: 2026-05-23_**Current phase: Phase 30 (top picks + backtest) complete and verifiedlocally 2026-05-23; not yet deployed.** Home page now carries a"Top picks" panel — four columns (Day / Week / Month / Year), 5 rankedstocks each, every row a verdict badge over a headline figure. A new`/backtest` page replays the picker over historical prices and showsstrategy vs `^SPX` equity, total return, CAGR, per-pick andper-period win rates, and a per-rebalance history table; horizon tabsswap the rendered horizon in place. Stocks-only across all fourhorizons (the user's design call); both win-rate definitions shownside-by-side; one chart with horizon tabs (vs four side-by-side).Migration `0009` adds the `picks` table; the scheduler's new `picks`section snapshots forward each day right after `daily_close`. Thebacktest uses today's standing applied historically (acknowledgedlook-ahead bias, surfaced in the page disclaimer) — for fun andtesting, not an out-of-sample evaluation; the user is explicit thisis for fun, not financial advice. **Phase 28 (ETFs as first-classcitizens) is complete, verified, and deployed to production (commit`2ae81d5`).** The new fund_metadata + sector/geography + ETF-distributionsdata populates async via the scheduler's first sec / fund_metadata /
@@ -858,6 +873,89 @@ schema, unused for now. history: 10y +320.6% / +15.45%/yr, since-inception (from the Feb 2005 first stored bar) +692.8% / +10.24%/yr.- **Phase 30 top picks + backtest.** Complete and verified locally 2026-05-23 — not yet deployed. A home-page "Top picks" panel of 5 forecast-horizon picks per horizon (Day / Week / Month / Year), and a new `/backtest` page that replays the picker over historical prices. Stocks-only across all four horizons per the user's design call; one chart with horizon tabs on the backtest page; both per-pick and per-period win rates surfaced. For fun and testing — explicitly not financial advice, a quiet disclaimer rides on both surfaces. - **Pick math (`compute.rs`)** — four pure `pick_*` rankers, each taking the shared `PickInput` (last price, prev close, daily closes, Phase 20 standing) and returning an `Option<f64>` (the headline figure that justified the pick, `None` when disqualified): - **Day:** today's intraday % move + a bias for sitting near the 52-week high; skips stocks the standing rates `Weak`. - **Week:** trailing 5-day % return, gated on RSI(14) being in `[30, 70]` and the close being above SMA50; same `Weak` filter. - **Month:** trailing 20-day % return, gated on the close being above SMA200; same `Weak` filter. - **Year:** the Phase 20 combined fundamentals + trajectory score, pass-through — the right answer for the year horizon already. - **Picks module (`src/picks.rs`)** glues the rankers to the DB: `HORIZONS` const, `compute_picks(bundles)` runs each ranker against every stock and returns one `PickSlate` (the 5 top per horizon), `load_bundles(pool)` builds the per-stock bundles in 3 queries (the same shape as `routes::home::load_stocks`), `snapshot_today(pool, date)` writes the result into the `picks` table. - **Migration `0009`** adds the `picks` table: `(snapshot_date, horizon, rank, ticker, score, price_at_pick)`, PK on the first three. One row per pick, replaced wholesale on each snapshot date (idempotent reruns). Frozen forward from the first deploy so the backtest reads immutable history, not today's algo replayed over old data (which an algo tweak would silently rewrite). - **Scheduler** gained a `picks` section right after `run_daily_close_if_due`. Keyed in `meta` on `picks_snapshot_date` so it fires exactly once per ET trading date, and gated on `daily_close_date` already being set so the picks are scored off fresh closes. Logs to `fetch_log` + flips `data_status` like every other job; surfaces on `/health` for free. - **Home page** (`routes/home.rs` + `home.html`) carries the "Top picks" section between Today's movers and Strongest & weakest. Computed live every render (cheap: the new pick scan + the existing standings scan together still come in under 600ms warm); a fixed page-load snapshot like the movers and standings panels, so the stream client does not stall on it. - **`/backtest` page** (`routes/backtest.rs` + `backtest.html` + `frontend/static_src/backtest/`): one page, four horizon tabs that swap content in place via `GET /api/backtest?horizon=…`. The JSON feed runs `picks::run_backtest`, which walks back from today by horizon stride (1/5/20/252 trading days), at each rebalance picks the top 5 with the same rankers, equal-weights them for one stride, rebalances at the next stride, and tracks both the strategy's and `^SPX`'s equity from a $10k anchor. Renders an equity curve area chart (lightweight-charts, mirroring the Phase 28 growth chart), four stat cards (strategy total + CAGR, benchmark total + CAGR, per-pick win rate, per-period win rate), and a per-rebalance history table with each period's picks color-tinted by their own return. - **Acknowledged look-ahead.** The backtest uses today's standing (Phase 7 fundamentals are not stored per period), so a stock that is `Strong` today filters into every historical pick. Surfaced explicitly in the page disclaimer; v1 trade-off, fits the user's "for fun and testing" framing. - **Disclaimer style** in `base.scss`: a quiet ink-faint italic line, smaller than body text, never carries semantic color. Used on both the home Top picks panel and the backtest page header. - **Performance**: the backtest load query was capped at the trailing 7 years (`HIST_LOOKBACK_DAYS`) so deep histories (`^SPX` back to 1789) do not pull a million-row scan; cold-cache `/api/backtest` runs ~1-2s per horizon, warm ~100ms. - Verified: cargo + bun build clean; `/` renders the four-column Top picks section on desktop (4-wide) and phone (2x2), every row carrying a verdict badge over a percent return (or the year score); `/backtest` loads the Month horizon by default with 49 rebalances over 4 years (`2022-05-25 → 2026-04-23`), the equity curve drawing the strategy and `^SPX` from $10k. Headline figures across all four horizons in local replay: day 250 rebalances / +76.8% vs ^SPX +27.2% / 54.1% per-pick win / 57.2% beat-bench; week 99 / +59.7% vs +41.3% / 52.7% / 46.5%; month 49 / +595.8% vs +78.7% / 58.2% / 61.2%; year 3 / +142.1% vs +47.6% / 66.7% / 100.0%. Phone view stacks the stat cards 2x2 and the rebalance table scrolls inside its card. Tabs swap horizons in place without a navigation. Zero console errors.- **Phase 26 dividend payouts.** Complete, verified, and deployed to production (commit `7608b06`). Per-payout dividend history sourced from Yahoo's chart endpoint via `events=div`, surfaced as a new Dividends section on the symbol page
@@ -942,7 +1040,9 @@ distributions ride the same code path. Phase 29 (issuer-direct ETF datafeeds: iShares/BlackRock, Vanguard, ...) was captured 2026-05-22 from avibe-coding side note mid-Phase-28; see the decisions log.After Phase 28 ships, remaining post-MVP work is the loose-orderedPhase 30 (top picks + backtest) is complete and verified locally2026-05-23 — not yet deployed; ships on the next `git push server master`.After Phase 30 deploys, remaining post-MVP work is the loose-orderedPhase 13, 15, 16, 17, 19, 25, 27, 29 backlog. Phase 26 (dividend payouts)is complete and deployed (commit `7608b06`); the MVP plus Phase 14,Phase 18, Phase 20, Phase 21, Phase 23 + 24, Phase 22 and Phase 26 are all
@@ -1589,6 +1689,128 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data). backlog. Depends on Phase 28 (the schema and the fund-profile scaffolding to overlay onto).- [x] **Phase 30: Top picks + backtest.** Complete and verified locally 2026-05-23; see the Phase 30 entry in Status and the decisions log. (Captured 2026-05-23 from a vibe-coding session right after Phase 28 deployed. Picked as the next phase to build on 2026-05-23.) The user's steer, verbatim: *"on the home page i'd like to use all the fundamentals and trajectory and stats we have in the system to show a new panel on the home page — this panel would be top 5 picks for day / week / month / year — this would be our best guess given time frames relevant to this information and all the information we have as to what we should invest in — i'd also like a feature tied to this in guess testing the results so this would be a separate page you go to that shows the results of our guess with a win rate and % gains if $ invested was done kind of thing as like a stress test to show how solid our pick rate is — also i know you are not a financial advisor i am not either this is just for fun and testing"*. Two surfaces, one shared picking engine. **Scope settled 2026-05-23 (see the decisions log).** Picks are *forecast-horizon* picks: each tier predicts who will do best over that forward horizon (day = movers / intraday momentum; year = the Phase 20 fundamentals + trajectory composite). The holding period is implicit in the horizon. A "for fun and testing — not financial advice" disclaimer rides quietly on both surfaces. **Planned pieces:** (1) **Per-horizon ranking math** in `compute.rs`. Four pure functions, each taking the same per-stock input bundle (latest price, full daily history, current-day intraday bars, Phase 7 fundamentals, Phase 20 standing) and returning a per-symbol score. Suggested signals — settle at build time: - **Day:** today's intraday return + a small "near 52-week high" bias + a fundamental-strength filter (no weak-graded stocks). Conceptually the movers panel sharpened by quality. - **Week (~5 trading days):** 5-day return + RSI not extreme (skip > 70 / < 30) + above SMA50. Short-momentum read. - **Month (~20 trading days):** 20-day return + above SMA200 + Phase 20 standing not weak. Medium-momentum read filtered by fundamental quality. - **Year (~252 trading days):** reuse Phase 20's combined score directly — fundamentals 2:1 + trajectory — which is already the year-horizon answer the app computes. (2) **Home-page panel** alongside movers and strongest / weakest. A "Top picks" section showing four small lists of 5 stocks each (Day / Week / Month / Year), each row a `verdict_badge` + ticker + the score's headline figure (the relevant return or composite). Either a 4-column row on desktop and stacked on phone, or a tabbed segment — design pass at build time. A quiet disclaimer line under the section header ("for fun and testing, not financial advice"). The panel is a fixed page-load snapshot, like movers and strongest / weakest (per Phase 11 / Phase 20), so the home render stays cheap. (3) **Daily pick snapshot** to a new `picks` table (migration `0009`), one row per (snapshot_date, horizon, rank 1-5, ticker, score, price_at_pick). Snapshotted by a new scheduler section right after the daily-close job (a known once-a-day market-close moment when every stock has a fresh close). This is the load-bearing piece for the backtest: without it the backtest can only replay today's algo over old data, which means *every algo tweak invalidates history*. With it the picks the app actually made on every past day are immutable and the backtest is honest. The first day the feature ships seeds day 1 of the table; the table grows from there. (No retroactive backfill — see the decisions log when it's settled.) (4) **Backtest page** at `/backtest`. A simulation: starting capital (default $10k), pick a horizon, rebalance into the day's 5 picks equal-weight every period (day picks = daily rebalance, year picks = yearly), accrue returns through the snapshot history. Outputs: - **Equity curve** (lightweight-charts area), $X simulated capital over time, with `^SPX` benchmark dashed on the same axes. - **Win rate.** Settle definition at build time — either per-pick (% of picks that gained over their horizon) or per-period (% of horizons where the basket beat the benchmark). The user's phrasing "win rate" suggests per-pick; the per-period view is sharper for strategy quality. Likely show both. - **% gain vs benchmark.** Total return of the strategy minus `^SPX` total return over the same window. Compounded annualised growth (CAGR) once the history is long enough. - **Tabular history.** Each snapshot's picks and how each fared. A "for fun and testing — not financial advice" disclaimer rides at the top of the page. (5) **Disclaimer.** A new shared `.disclaimer` style (base.scss) — a quiet ink-faint line, smaller than body text, never green / amber / red. Used on both the home panel and the backtest page. **Schema.** Migration `0009` adds `picks (snapshot_date TEXT, horizon TEXT, rank INTEGER, ticker TEXT, score REAL, price_at_pick REAL, PRIMARY KEY (snapshot_date, horizon, rank))`. No change to `symbols`. Compute is pure (no new network calls) — reuses the data the home and symbol routes already read. **Open design questions (settle at build time):** - **Universe per horizon.** Stocks-only on all four? Or include curated ETFs on the year horizon (a steadily growing fund is a legitimate one-year pick)? Probably stocks-only on day / week / month (fundamentals filter excludes ETFs anyway), open on year. - **Win-rate definition** — per-pick vs per-period (see piece 4). - **Backtest UI** — four separate charts (one per horizon) or one chart with a horizon toggle. The latter is tidier; the former enables side-by-side reads. - **Rebalance cost.** Skip transaction-cost modelling in v1 (single operator, no real money) and label the backtest as "frictionless" in its provenance line. A later refinement could add a flat bps drag. - **Retroactive backfill.** Skip in v1 — the snapshot table grows forward from the first deploy and the backtest is honest about "history since 2026-Mm-DD". Backfilling by replaying today's algo over past `daily_prices` would be tempting but lies about what the app actually said at the time, so it stays out. - **Disclaimer wording** — design pass with the user. **Anti-spam.** Zero new network calls. Compute is pure over data already stored (`daily_prices`, `intraday_bars`, `fundamentals`, `quotes`). No new `EndpointGuard` row. **Dependencies.** Phase 20 (its standing score is the year horizon directly, the strength filter on the shorter horizons). Phase 11 / Phase 20 home dashboard scaffolding to hang the panel on. No hard dependency on Phase 14 / 15 / 16 / 17 (could later layer leadership and industry signals into a future v2 of the picks).---## Key files
@@ -2358,6 +2580,86 @@ finance/ query in symbols.rs. No deploy yet — the local verification used a synthetic `fund_metadata` row injected via the python `sqlite3` module since Yahoo refuses this WSL2 IP.- **2026-05-23 — Phase 30 follow-up: `pick_day` now uses the last completed daily bar, not live intraday.** Mid-verification the user flagged the Day picks as reading "after the fact": a stock up 12% intraday today was being top-ranked for "today's pick", which is exactly the post-hoc trap (if the move happened during the session, buying on it means chasing what already happened). Two fixes shipped together: (1) `pick_day` now reads `closes[-1] / closes[-2] - 1` (most recent completed daily bar's close-to-close return), not `last_price / prev_close - 1`. After-hours both forms give the same number; during regular hours the new form returns yesterday's full-day move instead of today's intraday-so-far — a legitimate forward-looking momentum signal for tomorrow's continuation. The `52-week-high bias` was also refactored to read off the same `closes` series. `PickInput` dropped `last_price` / `prev_close` entirely, so every ranker now reads the same single-source slice and no signal can leak from intraday. (2) Horizon labels reframed forward-looking — `Tomorrow / Next week / Next month / Next year` (the `key` strings stay `day / week / month / year`, since the picks table stores them). The home panel's section note and disclaimer now both call the displayed figure "the signal that earned the pick" rather than implying it is a prediction of return. The picker is honest about doing momentum + quality, and honest that it does not know what tomorrow holds.- **2026-05-23 — Phase 30 top picks + backtest shipped (local).** Design Q&A settled three points before the build: stocks-only across all four horizons (the user's pick over including ETFs on the year horizon; the short rankers all filter on Phase 20 strength, which only stocks carry), both win-rate definitions shown side-by-side (per-pick = % of picks that gained over their horizon, per-period = % of horizons where the basket beat `^SPX`), and one chart with horizon tabs over four side-by-side charts (tidier, deeper detail per horizon). Build calls made during the work: (1) **acknowledged look-ahead bias from today's standing.** Per-period fundamentals are not stored, so the backtest applies today's `Strong/Fair/Weak` verdict to every historical date. Surfaced explicitly in the page disclaimer; a future phase could store point-in-time fundamentals if we ever want a clean out-of-sample run, but for v1 this matches the user's "for fun and testing" framing. (2) **`HIST_LOOKBACK_DAYS` cap of 7 years on the backtest load query.** A first run pulled ~948k rows from `daily_prices` (some indexes go back to the 1700s) and the API took 7s; the cap drops it to ~1-2s without losing any realistic backtest depth (the year horizon's 5 rebalances × 252 bars ≈ 5 years). (3) **The pick rankers' headline figures are the raw score**, so the home panel and the backtest both display the same number per row without a separate display pass — `+3.2%`, `+12.0%`, etc. (4) **`pick_year` returns Phase 20's combined score on a `× 100` scale** so the four lines of the home panel read on roughly the same magnitude; the year column shows the raw composite rather than a percent, which is honest about what the year ranker is ranking on. (5) **The snapshot job fires right after `daily_close`, keyed in `meta` on `picks_snapshot_date`**, and only proceeds once `daily_close_date` is also today's — so the picks are never scored off stale prices, and the job is idempotent across restarts.- **2026-05-23 — Phase 30 captured + picked next: top picks + backtest.** Resuming from a cleared context, the user floated a new feature mid- resume: a home-page panel of 5 forecast-horizon picks for day / week / month / year using the fundamentals / trajectory / stats the app already carries, plus a separate `/backtest` page that simulates following the picks and reports a win rate and $ gain. Two design questions settled the scope before write-up: (1) "pick" means *forecast-horizon* — each tier predicts who will do best over that forward horizon, with different signals per horizon (day = movers / momentum, year = Phase 20's combined fundamentals + trajectory score unchanged), holding period implicit; (2) picked as the next phase to build, ahead of the loose 13/15/16/17/19/25/27/29 queue. Budgeted as Phase 30 (Phase 29 already holds the issuer-direct ETF feeds backlog). The phase's most load-bearing call is the new `picks` table (migration `0009`): daily snapshot of each horizon's 5 picks right after the daily-close job, so the backtest runs against immutable historical picks rather than replaying today's algo over old data (every algo tweak would otherwise rewrite history). v1 grows the history forward from first deploy — no retroactive backfill, the backtest is honest about "history since". Anti-spam clean: zero new network calls, pure compute over data already stored. The user explicitly said "i know you are not a financial advisor, i am not either, this is just for fun and testing" — captured as a quiet disclaimer line on both surfaces. Open design questions noted in the phase entry (universe per horizon, win-rate definition, backtest UI layout, retroactive backfill, disclaimer wording) settle at build time per the vibe-coding norm.- **2026-05-22 — Phase 29 captured: issuer-direct ETF data feeds.** Mid-Phase-28 the user floated: *"as a future plan we tie into iShares/blackrock and Vanguard directly for even more data for their
added
frontend/static_src/backtest/index.js
@@ -0,0 +1,219 @@// 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); });}function load(horizon) { setActive(horizon); panel.innerHTML = '<p class="bt-status">Loading backtest…</p>'; 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);
added
frontend/static_src/backtest/styles/backtest.scss
@@ -0,0 +1,204 @@@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: 0 0 8px; max-width: 70ch; color: var(--ink-dim); font-size: 0.86rem;}/* horizon tabs (Day / Week / Month / Year) */.bt-tabs { display: flex; gap: 4px; margin: 18px 0 14px; border-bottom: 1px solid var(--rule);}.bt-tab { @include eyebrow; padding: 9px 14px; background: transparent; border: 0; border-bottom: 2px solid transparent; color: var(--ink-dim); 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: 30px 4px; color: var(--ink-faint); font-size: 0.88rem;}.bt-status--err { color: var(--down);}.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/base.scss
@@ -500,6 +500,18 @@ main { white-space: nowrap;}/* a link inside the asof slot (e.g. the Top picks "Backtest these →" CTA) reads as a quiet annotation, not a body link. Hover ramps to ink. */.section-title__asof a { color: var(--ink-dim);}.section-title__asof a:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 2px;}/* ---------- ticker grid + card ---------- */.ticker-grid { display: grid;
@@ -633,6 +645,17 @@ main { background: var(--down-soft);}/* ---------- 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 { margin: -8px 0 14px; font-size: 0.72rem; color: var(--ink-faint); font-style: italic;}/* ---------- desktop layering ---------- */@media (min-width: $bp-md) { .search {
modified
frontend/static_src/home/styles/home.scss
@@ -336,11 +336,127 @@ min-width: 5ch;}/* ---------- 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; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px;}.picks-col { @include card; padding: 10px 12px 8px; display: flex; flex-direction: column; gap: 4px; min-width: 0;}.picks-col__title { @include eyebrow; padding-bottom: 6px; border-bottom: 1px solid var(--rule-strong);}.picks-col__desc { margin: 0; font-size: 0.7rem; color: var(--ink-faint); line-height: 1.35; /* clamp to two lines on a phone so the column heights stay close */ min-height: 2.6em;}.picks-col__list { list-style: none; margin: 4px 0 0; padding: 0;}.picks-col__empty { margin: 8px 0; font-size: 0.78rem; color: var(--ink-faint);}.pick { border-bottom: 1px solid var(--rule);}.pick:last-child { border-bottom: 0;}.pick__row { display: grid; /* rank, sym, name, badge, score */ grid-template-columns: auto auto minmax(0, 1fr) auto auto; align-items: center; gap: 8px; padding: 7px 2px; color: var(--ink);}.pick__row:hover { color: var(--ink); background: var(--well);}.pick__rank { width: 1.5ch; text-align: right; font-size: 0.72rem; color: var(--ink-faint); font-weight: 600;}.pick__sym { font-weight: 700; font-size: 0.82rem;}.pick__name { font-size: 0.74rem; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}.pick__badge { display: flex;}.pick__score { font-size: 0.78rem; font-weight: 600; text-align: right; min-width: 4.4ch;}/* on a narrow phone the name yields so rank/sym/badge/score keep their shape — the name is implied by the ticker. */@media (max-width: $bp-sm) { .pick__name { display: none; } .pick__row { grid-template-columns: auto auto auto 1fr auto; }}/* ---------- desktop layering ---------- */@media (min-width: $bp-md) { .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
modified
frontend/vite.config.js
@@ -18,6 +18,7 @@ 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"), }, }, },
added
migrations/0009_picks.sql
@@ -0,0 +1,31 @@-- finance migration 0009: top picks snapshots (Phase 30).---- A new `picks` table records, for each (snapshot_date, horizon), the five-- stocks the app's forecast-horizon picker named at end-of-day. Snapshotted by-- the scheduler right after `daily_close` runs (a known once-per-day moment-- when every symbol carries a fresh close), one row per pick.---- The snapshot is what makes the `/backtest` page honest: without it the-- backtest could only replay today's algo over old data, and every algo tweak-- would silently rewrite history. With it, the picks the app actually made on-- every past trading day are immutable; the backtest reads them back and-- simulates following them. v1 grows the table forward from the first deploy-- — no retroactive backfill — so the backtest is honest about "history since".---- `score` is the per-horizon ranker's raw value (higher is better); kept for-- debugging and for the backtest's stat table. `price_at_pick` is the close-- the pick was named at, so the backtest can compute the per-pick return at-- the next snapshot without re-reading `daily_prices`.CREATE TABLE picks ( snapshot_date TEXT NOT NULL, -- YYYY-MM-DD ET trading date horizon TEXT NOT NULL, -- day | week | month | year rank INTEGER NOT NULL, -- 1..5 ticker TEXT NOT NULL REFERENCES symbols(ticker) ON DELETE CASCADE, score REAL NOT NULL, -- ranker's raw score, higher is better price_at_pick REAL NOT NULL, -- close used as the entry price PRIMARY KEY (snapshot_date, horizon, rank));CREATE INDEX picks_ticker ON picks(ticker);CREATE INDEX picks_date ON picks(snapshot_date);
@@ -105,6 +105,7 @@ pub fn router(state: AppState) -> Router { Router::new() .merge(routes::home::router()) .merge(routes::backtest::router()) .merge(routes::symbols::router()) .merge(routes::search::router()) .merge(routes::stream::router())
@@ -1107,6 +1107,185 @@ 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)}/// Year-horizon score: the Phase 20 combined fundamentals-and-trajectory/// score directly. The year horizon's right answer is already what `standing`/// computes — quality businesses with healthy trajectory — so we pass it/// through rather than invent a second long-horizon read.pub fn pick_year(i: &PickInput<'_>) -> Option<f64> { Some(i.standing?.score * 100.0)}#[cfg(test)]mod phase28_tests { use super::*;
@@ -5,6 +5,7 @@ mod guard;mod market;mod middleware;mod models;mod picks;mod providers;mod render;mod routes;
@@ -0,0 +1,746 @@//! Top picks across four forecast horizons (Phase 30).//!//! Each horizon has a separate ranker in `compute` (`pick_day` / `pick_week`//! / `pick_month` / `pick_year`). 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: the//! short rankers filter on Phase 20 fundamental strength, which only stocks//! carry, and the year horizon delegates to the Phase 20 standing directly.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: "year", label: "Next year", desc: "Phase 20 combined fundamentals + 12-month trajectory score (no recent-move signal)", },];/// 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), "year" => compute::pick_year(&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)> = sqlx::query_as( "SELECT f.ticker, f.metric, f.period, f.fiscal_year, f.fiscal_qtr, f.value \ FROM fundamentals f JOIN symbols s ON s.ticker = f.ticker \ WHERE s.is_seeded = 1 AND s.kind = 'stock'", ) .fetch_all(pool) .await?; let mut facts: HashMap<String, Vec<models::FundFact>> = HashMap::new(); for (ticker, metric, period, fiscal_year, fiscal_qtr, value) in fact_rows { facts.entry(ticker).or_default().push(models::FundFact { metric, period, fiscal_year, fiscal_qtr, value, }); } // 3. 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.//// Acknowledged look-ahead: fundamentals are today's (we do not store a// per-period history of Phase 7 facts). The page's disclaimer surfaces// this — the backtest is a stress test "for fun and testing", not a clean// out-of-sample evaluation./// 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 standing carried as today's value/// (look-ahead bias acknowledged), and the full close history the rankers/// walk over.pub struct HistBundle { pub ticker: String, pub bars: Vec<HistBar>, pub standing: Option<Standing>,}/// 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 (year, ~5 rebalances × 252 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, "year" => 252, _ => 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, year too few. Roughly 1y / 2y / 4y / 5y.fn max_bars_for(horizon_key: &str) -> usize { match horizon_key { "day" => 252, "week" => 504, "month" => 1008, "year" => 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' price, plus today's standing as for the live // picks. (Name not needed: the backtest table shows tickers only.) let price_rows: Vec<(String, Option<f64>)> = sqlx::query_as( "SELECT s.ticker, \ 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?; let fact_rows: Vec<(String, String, String, i64, Option<i64>, f64)> = sqlx::query_as( "SELECT f.ticker, f.metric, f.period, f.fiscal_year, f.fiscal_qtr, f.value \ FROM fundamentals f JOIN symbols s ON s.ticker = f.ticker \ WHERE s.is_seeded = 1 AND s.kind = 'stock'", ) .fetch_all(pool) .await?; let mut facts: HashMap<String, Vec<models::FundFact>> = HashMap::new(); for (ticker, metric, period, fiscal_year, fiscal_qtr, value) in fact_rows { facts.entry(ticker).or_default().push(models::FundFact { metric, period, fiscal_year, fiscal_qtr, value, }); } // 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, last_price)| { let bars = bars_by.remove(&ticker).unwrap_or_default(); let closes: Vec<f64> = bars.iter().map(|b| b.close).collect(); let standing = facts.get(&ticker).and_then(|f| { let inputs = models::latest_annual_inputs(f, last_price)?; compute::standing(&compute::compute_ratios(&inputs), &closes) }); HistBundle { ticker, bars, standing, } }) .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 bar index `idx` (within the benchmark's date list)./// Each bundle is sliced to its own bars at-or-before the benchmark's/// `as_of` date, with `last_price` and `prev_close` derived from that slice./// A stock that does not yet have two bars by that date is silently dropped.fn rank_at( bundles: &[HistBundle], as_of: &str, horizon_key: &str,) -> Vec<(String, f64, f64)> { let mut scored: Vec<(String, f64, f64)> = bundles .iter() .filter_map(|b| { // 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 prev_close = b.bars[idx - 1].close; let closes: Vec<f64> = b.bars[..=idx].iter().map(|x| x.close).collect(); let input = PickInput { closes: &closes, standing: b.standing, }; // `last_price` and `prev_close` are read above only for the // entry-price recording below; the rankers themselves consume // `closes` exclusively. let _ = (last_price, prev_close); let score = match horizon_key { "day" => compute::pick_day(&input), "week" => compute::pick_week(&input), "month" => compute::pick_month(&input), "year" => compute::pick_year(&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, }}
added
src/routes/backtest.rs
@@ -0,0 +1,90 @@//! `/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>`.//!//! The fundamental signals are today's values (we do not store a per-period//! history of Phase 7 ratios), which is acknowledged look-ahead bias —//! surfaced explicitly in the page's disclaimer. The user's own framing of//! the feature is "for fun and testing", so this is the right trade-off for//! v1; a future phase can layer in point-in-time fundamentals.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 | year`. 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,6 +16,7 @@ use serde::Serialize;use crate::compute::{self, Sparkline};use crate::market;use crate::models;use crate::picks;use crate::render::render;use crate::AppState;
@@ -160,6 +161,16 @@ async fn home(State(state): State<AppState>) -> Response { let (gainers, losers) = movers(&stocks); let (strongest, weakest) = strength_panels(&stocks); // 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.
@@ -179,6 +190,7 @@ async fn home(State(state): State<AppState>) -> Response { strongest => strongest, weakest => weakest, standings_asof => standings_asof, pick_slates => pick_slates, total => total, }; render(&state, "pages/home.html", "/", extra)
modified
src/routes/mod.rs
@@ -1,3 +1,4 @@pub mod backtest;pub mod health;pub mod home;pub mod search;
modified
src/scheduler.rs
@@ -40,7 +40,7 @@ use crate::providers::{ PortfolioData, Quote, QuoteProvider,};use crate::stream::{Hub, QuoteUpdate, StreamEvent};use crate::{seed, Config};use crate::{picks, 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
@@ -249,6 +249,15 @@ 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:#}"); }
@@ -648,6 +657,54 @@ 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,6 +67,43 @@</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
added
templates/pages/backtest.html
@@ -0,0 +1,40 @@{% 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 and simulates following its top 5 picks at each rebalance. Each strategy shown is rebalanced equal-weight at the end of every horizon period and compared to <code>^SPX</code> over the same window.</p> <p class="disclaimer">For fun and testing. Not financial advice. The picker uses today’s fundamentals applied to historical prices (per-period fundamentals are not stored) — treat as a stress test of the ranker, not an out-of-sample evaluation. Frictionless: no transaction 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"> <p class="bt-status">Loading backtest…</p> </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 %} {% from "includes/macros.html" import spark_card, mover_row, standing_row, picks_column %} <div class="page-head"> <h1>Markets</h1>
@@ -56,6 +56,20 @@ </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 year — 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">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