← 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
M public/app.jsM public/index.htmlM server.jsM test/panel.spec.mjs
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 →