← 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('');