← back to Stock Vshape Viewer
DTD 7/8-B rework: gate dip-depth reward on newHigh (kill falling-knife), drop redundant new-high +8; add decile forward-return backtest. Finding: buy-score is INVERTED at 20-60d but works at ~120d (corr +0.67, 40-79 bands beat baseline +7-12%)
d251eb0a60099992a210d41b96907d2c5f29987d · 2026-08-27 17:25:03 -0700 · Steve
Files touched
A backtest/decile-backtest.mjsM public/screen.js
Diff
commit d251eb0a60099992a210d41b96907d2c5f29987d
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Aug 27 17:25:03 2026 -0700
DTD 7/8-B rework: gate dip-depth reward on newHigh (kill falling-knife), drop redundant new-high +8; add decile forward-return backtest. Finding: buy-score is INVERTED at 20-60d but works at ~120d (corr +0.67, 40-79 bands beat baseline +7-12%)
---
backtest/decile-backtest.mjs | 85 ++++++++++++++++++++++++++++++++++++++++++++
public/screen.js | 8 +++--
2 files changed, 90 insertions(+), 3 deletions(-)
diff --git a/backtest/decile-backtest.mjs b/backtest/decile-backtest.mjs
new file mode 100644
index 0000000..cf4ce2a
--- /dev/null
+++ b/backtest/decile-backtest.mjs
@@ -0,0 +1,85 @@
+// Decile forward-return backtest for the buy-score (DTD 7/8-B validation step).
+// Replays SCREEN.analyze across history for a diversified sample, records each day's
+// buy-score + its H-day forward return, then buckets forward returns by score band.
+// If higher score bands beat the all-sample baseline, the ordering has real signal.
+// Run: node backtest/decile-backtest.mjs (needs the server up on 9822)
+import { createRequire } from 'module';
+import path from 'path';
+import { fileURLToPath } from 'url';
+const require = createRequire(import.meta.url);
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const SCREEN = require(path.join(__dirname, '../public/screen.js'));
+
+const BASE = process.argv[2] || 'http://localhost:9822';
+const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
+const H = +(process.argv[3]) || 20; // forward horizon (trading days), override: node ... <url> <H>
+const TF = 126; // trailing window used to score (6mo default)
+const F = SCREEN.DEFAULTS;
+
+// diversified sample across sectors + a few ETFs (multiple regimes, not one trade)
+const TICKERS = ['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'];
+
+async function hist(t) {
+ try {
+ const r = await fetch(`${BASE}/api/history?ticker=${t}`, { headers: { Authorization: AUTH } });
+ const j = await r.json();
+ return j.days && j.days.length > TF + H + 10 ? j.days : null;
+ } catch { return null; }
+}
+
+const rows = []; // {score, fwd}
+let used = 0, skipped = 0;
+for (const t of TICKERS) {
+ const full = await hist(t);
+ if (!full) { skipped++; continue; }
+ used++;
+ const closes = full.map(d => d.close);
+ const ma50 = SCREEN.sma(closes, 50), ma200 = SCREEN.sma(closes, 200);
+ for (let tEnd = TF; tEnd < full.length - H; tEnd += 2) { // step 2 days to halve compute
+ const days = full.slice(tEnd - TF, tEnd);
+ const an = SCREEN.analyze(days, ma50.slice(tEnd - TF, tEnd), ma200.slice(tEnd - TF, tEnd), F);
+ const fwd = full[tEnd + H].close / full[tEnd].close - 1;
+ if (isFinite(fwd)) rows.push({ score: an.buyScore, fwd });
+ }
+}
+
+// baseline (random pick from the sampled days)
+const mean = a => a.reduce((s, x) => s + x, 0) / (a.length || 1);
+const allFwd = rows.map(r => r.fwd);
+const baseRet = mean(allFwd), baseWin = allFwd.filter(x => x > 0).length / allFwd.length;
+
+// score bands
+const BANDS = [[0, 19], [20, 39], [40, 59], [60, 79], [80, 100]];
+console.log(`\n=== Buy-score forward-return backtest ===`);
+console.log(`sample: ${used} tickers (${skipped} skipped) · ${rows.length} scored days · forward horizon ${H}d\n`);
+console.log(`BASELINE (any day, random): mean fwd ${(baseRet * 100).toFixed(2)}% · win-rate ${(baseWin * 100).toFixed(1)}%\n`);
+console.log(`band n mean fwd% win% vs baseline`);
+const bandStats = [];
+for (const [lo, hi] of BANDS) {
+ const sub = rows.filter(r => r.score >= lo && r.score <= hi);
+ const f = sub.map(r => r.fwd);
+ const mret = f.length ? mean(f) : 0, win = f.length ? f.filter(x => x > 0).length / f.length : 0;
+ bandStats.push({ lo, hi, n: f.length, mret, win });
+ const lift = (mret - baseRet) * 100;
+ console.log(`${String(lo).padStart(2)}-${String(hi).padStart(3)} ${String(f.length).padStart(6)} ${(mret * 100).toFixed(2).padStart(7)}% ${(win * 100).toFixed(1).padStart(5)}% ${(lift >= 0 ? '+' : '') + lift.toFixed(2)}%`);
+}
+
+// verdict: does the top band beat baseline, and are bands monotone-ish?
+const top = bandStats[bandStats.length - 1], hi2 = bandStats[bandStats.length - 2];
+const topLift = (top.n ? top.mret : hi2.mret) - baseRet;
+const topBand = top.n >= 30 ? top : hi2;
+console.log(`\n--- read ---`);
+console.log(`top populated band (${topBand.lo}-${topBand.hi}, n=${topBand.n}): mean fwd ${(topBand.mret * 100).toFixed(2)}% vs baseline ${(baseRet * 100).toFixed(2)}% → ${topBand.mret > baseRet ? 'BEATS' : 'does NOT beat'} baseline by ${((topBand.mret - baseRet) * 100).toFixed(2)}%`);
+// monotonicity: correlation of band-index vs mean return (populated bands only)
+const pop = bandStats.filter(b => b.n >= 30);
+let mono = 'n/a';
+if (pop.length >= 3) {
+ const xs = pop.map((_, i) => i), ys = pop.map(b => b.mret);
+ const mx = mean(xs), my = mean(ys);
+ const cov = mean(xs.map((x, i) => (x - mx) * (ys[i] - my)));
+ const sx = Math.sqrt(mean(xs.map(x => (x - mx) ** 2))), sy = Math.sqrt(mean(ys.map(y => (y - my) ** 2)));
+ const corr = sx && sy ? cov / (sx * sy) : 0;
+ mono = corr.toFixed(2);
+ console.log(`band-index → mean-return correlation: ${mono} (${corr > 0.4 ? 'higher score → higher fwd return ✓' : corr < -0.4 ? 'INVERTED — higher score → LOWER return ✗' : 'weak/no monotonic relationship'})`);
+}
+console.log('');
diff --git a/public/screen.js b/public/screen.js
index 5b29f2b..585ade5 100644
--- a/public/screen.js
+++ b/public/screen.js
@@ -124,13 +124,15 @@
else if (near(d50)) { zoneCls = 'watch'; zoneLabel = 'Testing 50-DMA'; }
else if (d50 != null && d50 > 12) { zoneCls = 'ext'; zoneLabel = 'Extended above 50-DMA — wait for pullback'; }
- // composite BUY SCORE 0-100 (a heuristic "worth a look" rank, not advice)
+ // composite BUY SCORE 0-100 (a heuristic "worth a look" rank, not advice).
+ // DTD 7/8-B rework: dip-depth reward is GATED on newHigh (reward a RECLAIMED deep
+ // dip, never a still-falling knife); redundant new-high +8 removed (it duplicated
+ // the fits gate and didn't change the ranking of surfaced candidates).
let score = 0;
if (fits) score += 30;
- score += Math.min(20, Math.max(0, Math.abs(drawdown) - f.minDip));
+ if (newHigh) score += Math.min(20, Math.max(0, Math.abs(drawdown) - f.minDip)); // depth of a RECOVERED dip only
if (rsiNow != null) { if (rsiNow >= 40 && rsiNow <= 60) score += 15; else if (rsiNow < 40) score += 12; else if (rsiNow > 70) score -= 10; }
if (zoneCls === 'buy2') score += 22; else if (zoneCls === 'buy') score += 18; else if (zoneCls === 'watch') score += 10; else if (zoneCls === 'ext') score -= 6;
- if (newHigh) score += 8;
if (volSurge > 1.3) score += 5;
score = Math.max(0, Math.min(100, Math.round(score)));
← 368c737 Fix scan race: await autoLoadMatches before printing 'loaded
·
back to Stock Vshape Viewer
·
auto-data-snapshot: 2026-08-27T17:33:56 (1 data files) — .de 7b59452 →