[object Object]

← back to Stock Vshape Viewer

Ship the backtest into the UI: /api/backtest (built at boot, cached) + in-app score-backtest panel showing forward-return by score band across 20/60/120d; reframe 'buy score' -> 'setup score ~6mo' per the validated finding; spec now 15 checks

340e7453514b59e3aed9209851d54c84b98b64d5 · 2026-08-27 22:34:42 -0700 · Steve

Files touched

Diff

commit 340e7453514b59e3aed9209851d54c84b98b64d5
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 27 22:34:42 2026 -0700

    Ship the backtest into the UI: /api/backtest (built at boot, cached) + in-app score-backtest panel showing forward-return by score band across 20/60/120d; reframe 'buy score' -> 'setup score ~6mo' per the validated finding; spec now 15 checks
---
 public/app.js       | 42 +++++++++++++++++++++++++++++++++++++++++-
 public/index.html   |  2 ++
 server.js           | 44 ++++++++++++++++++++++++++++++++++++++++++++
 test/panel.spec.mjs |  6 ++++++
 4 files changed, 93 insertions(+), 1 deletion(-)

diff --git a/public/app.js b/public/app.js
index e3b03a0..85cf16e 100644
--- a/public/app.js
+++ b/public/app.js
@@ -308,7 +308,7 @@ function renderDetailBody(t, D) {
   const dd = Math.abs(an.drawdown) >= state.filters.minDip;
   const rsiCol = an.rsi == null ? 'var(--dim)' : an.rsi < 35 ? 'var(--pass)' : an.rsi > 70 ? 'var(--fail)' : 'var(--ink)';
   document.getElementById('d-kpis').innerHTML = `
-    <div class="kpi"><span>buy score</span><b style="color:${an.buyScore>=60?'var(--pass)':an.buyScore>=40?'var(--watch)':'var(--dim)'}">${an.buyScore}</b>/100</div>
+    <div class="kpi"><span>setup score</span><b style="color:${an.buyScore>=60?'var(--pass)':an.buyScore>=40?'var(--watch)':'var(--dim)'}" title="backtested ~6-month setup signal, not a short-term timer — see Score backtest below">${an.buyScore}</b>/100 · ~6mo</div>
     <div class="kpi"><span>RSI(14)</span><b style="color:${rsiCol}">${an.rsi==null?'—':an.rsi}</b>${an.rsi==null?'':an.rsi<35?'oversold':an.rsi>70?'overbought':'neutral'}</div>
     <div class="kpi"><span>vol surge</span><b style="color:${an.volSurge>1.3?'var(--pass)':'var(--ink)'}">${an.volSurge}×</b>vs 20d</div>
     <div class="kpi"><span>price velocity</span><b style="color:${an.priceVel>=0?'var(--pass)':'var(--fail)'}">${an.priceVel>=0?'+':''}${an.priceVel}%</b>/10d</div>
@@ -654,6 +654,45 @@ async function loadOptions(t) {
   } catch (e) { box.innerHTML = `<div style="color:var(--ext)">options error: ${e.message}</div>`; }
 }
 
+// ============================ score backtest (does it actually work?) ============================
+let btPollTimer = null;
+async function loadBacktest() {
+  const box = document.getElementById('d-backtest');
+  box.innerHTML = '<div style="color:#6f83a8">running the backtest (44 names × 2yr)…</div>';
+  clearTimeout(btPollTimer);
+  const poll = async () => {
+    try {
+      const j = await (await fetch('/api/backtest')).json();
+      if (j.status !== 'ready') { box.innerHTML = '<div style="color:#6f83a8">building the backtest… a few seconds</div>'; btPollTimer = setTimeout(poll, 3000); return; }
+      renderBacktest(j);
+    } catch (e) { box.innerHTML = `<div style="color:var(--ext)">backtest error: ${e.message}</div>`; }
+  };
+  poll();
+}
+function renderBacktest(j) {
+  const box = document.getElementById('d-backtest');
+  const pct = x => (x >= 0 ? '+' : '') + (x * 100).toFixed(1) + '%';
+  const BANDS = [[0, 19], [20, 39], [40, 59], [60, 79], [80, 100]];
+  let html = `<div style="color:#6f83a8;margin:2px 0 4px">${j.tickers} names · ${j.sampleDays} sample days · mean forward return by score band</div>`;
+  html += `<table class="daily show" style="font-size:11px"><tr><th>score band</th>`;
+  j.horizons.forEach(h => html += `<th>${h.H}d</th>`);
+  html += `</tr><tr><td style="color:var(--dim)">any day (baseline)</td>`;
+  j.horizons.forEach(h => html += `<td style="color:var(--dim)">${pct(h.baseline)}</td>`);
+  html += `</tr>`;
+  for (const [lo, hi] of BANDS) {
+    html += `<tr><td>${lo}-${hi}</td>`;
+    j.horizons.forEach(h => { const b = h.bands.find(x => x.lo === lo); if (!b || b.n < 15) { html += `<td style="color:#4a5877">·</td>`; return; }
+      html += `<td class="${b.mret > h.baseline ? 'up' : 'down'}" title="n=${b.n}, win ${(b.win * 100).toFixed(0)}%">${pct(b.mret)}</td>`; });
+    html += `</tr>`;
+  }
+  html += `</table>`;
+  const c = H => (j.horizons.find(h => h.H === H) || {}).corr;
+  html += `<div style="font-size:11px;margin-top:6px;line-height:1.5">
+    <b style="color:var(--newhigh)">Read:</b> score→return correlation is <b class="${c(20) >= 0 ? 'up' : 'down'}">${c(20)}</b> at 20d vs <b class="up">${c(120)}</b> at 120d.
+    The buy-score is a <b>~6-month setup signal</b>, not a short-term timer — a high score means "start a position to hold months," not "it pops next week." (Sample: large-caps + ETFs, recent 2yr; not risk-adjusted.)</div>`;
+  box.innerHTML = html;
+}
+
 function wireScanAndOptions() {
   document.getElementById('scan-btn').onclick = startScan;
   document.getElementById('go-scan').onclick = startScan;
@@ -677,6 +716,7 @@ function wireScanAndOptions() {
   });
   // lazy-load options when the detail toggle is clicked
   document.getElementById('d-opttoggle').onclick = () => { if (state.selected) loadOptions(state.selected); };
+  document.getElementById('d-bttoggle').onclick = loadBacktest;
   // volume-by-price layer toggle
   const vlt = document.getElementById('vol-layer-toggle');
   const syncVlt = () => { vlt.style.opacity = state.volLayer ? '1' : '0.4'; };
diff --git a/public/index.html b/public/index.html
index 8832f80..60e4b1f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -271,6 +271,8 @@
   <table class="daily" id="d-daily"></table>
   <div class="listtoggle" id="d-opttoggle" style="color:#2ee6c8">▸ Cheap far-dated options (puts & calls)</div>
   <div id="d-opts"></div>
+  <div class="listtoggle" id="d-bttoggle" style="color:#ffd54a">▸ Score backtest — does the buy-score actually work?</div>
+  <div id="d-backtest"></div>
   <div class="vnote" id="d-vnote"></div>
 </div>
 
diff --git a/server.js b/server.js
index 8a38eec..fd6b64e 100644
--- a/server.js
+++ b/server.js
@@ -96,6 +96,44 @@ async function getOptions(t) {
   OCACHE.set(t, { ts: Date.now(), payload }); return payload;
 }
 
+// ---------------- score backtest (built once at boot, cached) ----------------
+let BT = null, BT_TS = 0, BT_BUILDING = false; const BT_TTL = 6 * 60 * 60 * 1000;
+const BT_SAMPLE = ['NVDA','AAPL','MSFT','AMZN','GOOGL','META','TSLA','AVGO','JPM','BAC','WMT','XOM','CVX','UNH','LLY','JNJ','PG','KO','PEP','HD','MCD','NKE','DIS','NFLX','CRM','ORCL','AMD','INTC','CSCO','QCOM','TXN','IBM','GE','CAT','BA','MMM','HON','UPS','SPY','QQQ','XLK','XLF','XLE','SMH'];
+const BT_HORIZONS = [20, 60, 120];
+const BT_BANDS = [[0, 19], [20, 39], [40, 59], [60, 79], [80, 100]];
+async function buildBacktest() {
+  if (BT_BUILDING) return; BT_BUILDING = true;
+  try {
+    const F = SCREEN.DEFAULTS, TF = 126;
+    const rows = { 20: [], 60: [], 120: [] }; let used = 0;
+    for (const t of BT_SAMPLE) {
+      let full; try { full = (await getHistory(t)).days; } catch { continue; }
+      if (!full || full.length < TF + 130) continue; used++;
+      const closes = full.map(d => d.close);
+      const ma50 = SCREEN.sma(closes, 50), ma200 = SCREEN.sma(closes, 200);
+      for (let tE = TF; tE < full.length - Math.max(...BT_HORIZONS); tE += 3) {
+        const an = SCREEN.analyze(full.slice(tE - TF, tE), ma50.slice(tE - TF, tE), ma200.slice(tE - TF, tE), F);
+        for (const H of BT_HORIZONS) { const fwd = full[tE + H].close / full[tE].close - 1; if (isFinite(fwd)) rows[H].push({ s: an.buyScore, f: fwd }); }
+      }
+    }
+    const mean = a => a.reduce((x, y) => x + y, 0) / (a.length || 1);
+    const horizons = BT_HORIZONS.map(H => {
+      const all = rows[H].map(r => r.f), base = mean(all), baseWin = all.filter(x => x > 0).length / (all.length || 1);
+      const bands = BT_BANDS.map(([lo, hi]) => {
+        const f = rows[H].filter(r => r.s >= lo && r.s <= hi).map(r => r.f);
+        return { lo, hi, n: f.length, mret: f.length ? mean(f) : 0, win: f.length ? f.filter(x => x > 0).length / f.length : 0 };
+      });
+      const pop = bands.filter(b => b.n >= 30);
+      let corr = 0;
+      if (pop.length >= 3) { const xs = pop.map((_, i) => i), ys = pop.map(b => b.mret), mx = mean(xs), my = mean(ys);
+        const cov = mean(xs.map((x, i) => (x - mx) * (ys[i] - my))), sx = Math.sqrt(mean(xs.map(x => (x - mx) ** 2))), sy = Math.sqrt(mean(ys.map(y => (y - my) ** 2)));
+        corr = sx && sy ? cov / (sx * sy) : 0; }
+      return { H, baseline: base, baselineWin: baseWin, bands, corr: +corr.toFixed(2) };
+    });
+    BT = { asOf: new Date().toISOString(), tickers: used, sampleDays: rows[20].length, horizons }; BT_TS = Date.now();
+  } catch (e) { /* leave BT null */ } finally { BT_BUILDING = false; }
+}
+
 // ---------------- universe scan job ----------------
 function filtersFromQuery(p) {
   const num = (k, d) => p.has(k) ? +p.get(k) : d, bool = (k) => p.get(k) === 'true';
@@ -162,6 +200,11 @@ const server = http.createServer(async (req, res) => {
         minDays: Math.round((+(P.get('minMonths') || 3)) * 30.4), type: P.get('type') || 'both', underlying: o.underlying });
       return jsonRes(res, 200, { ticker: t, underlying: o.underlying, asOf: o.asOf, total: o.chain.length, count: screened.length, contracts: screened.slice(0, 60) });
     }
+    if (pathname === '/api/backtest') {
+      if (BT && Date.now() - BT_TS < BT_TTL) return jsonRes(res, 200, { status: 'ready', ...BT });
+      if (!BT_BUILDING) buildBacktest();
+      return jsonRes(res, 200, { status: 'building' });
+    }
     if (pathname === '/api/scan/start') {
       const filters = filtersFromQuery(P), tfN = +(P.get('tfN') || 126), max = Math.min(+(P.get('max') || 1500), 4200);
       const id = 'job' + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36);
@@ -195,4 +238,5 @@ server.listen(process.env.PORT ? Number(process.env.PORT) : 0, () => {
   console.log(`\n  📈  Stock V-Shape screener (LIVE + scan + options)`);
   console.log(`  →  http://localhost:${port}/   auth: ${USER} / ${PASS}`);
   console.log(`  →  history: Stooq/Yahoo · universe: NASDAQ+DJIA · options: CBOE (all free)\n`);
+  setTimeout(() => buildBacktest(), 3000); // build the score backtest in the background after boot
 });
diff --git a/test/panel.spec.mjs b/test/panel.spec.mjs
index 547b39f..e8be67c 100644
--- a/test/panel.spec.mjs
+++ b/test/panel.spec.mjs
@@ -106,6 +106,12 @@ const run = async () => {
   await page.waitForTimeout(500);
   ok('brush → sets custom range', await page.evaluate(() => /Custom range/.test(document.getElementById('d-vnote').textContent)));
 
+  // --- SCORE BACKTEST ---
+  await page.evaluate(() => document.getElementById('d-bttoggle').click());
+  await page.waitForTimeout(2500);
+  const bt = await page.evaluate(() => document.getElementById('d-backtest').textContent);
+  ok('backtest panel responds (building or data, no error)', /names|building|forward return|Read|correlation/.test(bt) && !/error/i.test(bt), bt.slice(0, 40));
+
   // --- console cleanliness ---
   ok('no JS/console errors', jsErrors.length === 0, jsErrors.slice(0, 3).join(' | '));
 

← 5b21ae4 One-command gated redeploy: redeploy.sh runs panel spec agai  ·  back to Stock Vshape Viewer  ·  Score transparency: expose scoreParts from the engine + rend ef0e646 →