[object Object]

← 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

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 →