← back to Stock Vshape Viewer
backtest/decile-backtest.mjs
86 lines
// 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('');