Spaces:
Running
Running
File size: 5,390 Bytes
f43243e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 | /**
* @module backtest/validate
* Robustness checks for the blockRanging change, on already-cached candles
* (no network / keys needed). Answers three questions the aggregate A/B can't:
* 1. Per-symbol — does blockRanging help every symbol, or just one?
* 2. In-sample vs out-of-sample — does it win in BOTH halves of the data?
* 3. Fill sensitivity — does it win under pessimistic AND optimistic fills?
*
* npm run backtest:validate
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { backtestSymbol, computeStats } from './backtester.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CACHE_DIR = path.resolve(__dirname, '../../scratch/bt-cache');
const SYMBOLS = ['BTCUSDT', 'XAUUSD', 'GBPUSD', 'USDCAD'];
const CONFIGS = { baseline: {}, blockRanging: { blockRanging: true } };
const fmt = (v, d = 2) => (Number.isFinite(v) ? v.toFixed(d) : (v === Infinity ? '∞' : '—'));
const pad = (s, w) => String(s).padStart(w);
function loadCache() {
const out = {};
for (const sym of SYMBOLS) {
const f = path.join(CACHE_DIR, `${sym}-15m.json`);
if (fs.existsSync(f)) out[sym] = JSON.parse(fs.readFileSync(f, 'utf8'));
}
return out;
}
/** Run a config across symbols on a slice; return per-symbol stats + combined. */
function run(candlesBySym, strategyOpts, tieBreak, sliceFn) {
const all = [];
const per = {};
for (const [sym, candles] of Object.entries(candlesBySym)) {
const c = sliceFn ? sliceFn(candles) : candles;
if (c.length < 260) continue;
const { trades } = backtestSymbol(c, sym, { tieBreak, strategyOpts });
per[sym] = computeStats(trades, sym);
all.push(...trades);
}
return { per, combined: computeStats(all, 'ALL') };
}
function row(label, s, w = 18) {
return [
label.padEnd(w), pad(s.trades, 7), pad(fmt(s.winRate, 1), 7),
pad(fmt(s.profitFactor), 6), pad(fmt(s.netPnL), 10), pad(fmt(s.expectancy), 9),
pad(fmt(s.maxDrawdown), 9),
].join(' ');
}
function header(label, w = 18) {
const h = [label.padEnd(w), pad('Trades', 7), pad('Win%', 7), pad('PF', 6), pad('Net $', 10), pad('Expect', 9), pad('MaxDD', 9)].join(' ');
console.log('\n' + h); console.log('-'.repeat(h.length));
}
function main() {
const data = loadCache();
if (Object.keys(data).length === 0) {
console.error('No cached candles. Run `npm run backtest -- --refresh` first.');
process.exit(1);
}
const base = run(data, CONFIGS.baseline, 'pessimistic');
const blk = run(data, CONFIGS.blockRanging, 'pessimistic');
// 1) Per-symbol: baseline vs blockRanging.
console.log('\n=== 1) PER-SYMBOL (pessimistic, full data) ===');
header('Symbol / config');
let helped = 0, total = 0;
for (const sym of Object.keys(base.per)) {
total++;
console.log(row(`${sym} baseline`, base.per[sym]));
console.log(row(`${sym} blockRanging`, blk.per[sym]));
const d = blk.per[sym].netPnL - base.per[sym].netPnL;
if (d > 0) helped++;
console.log(` Δnet = ${d >= 0 ? '+' : ''}${fmt(d)} (PF ${fmt(base.per[sym].profitFactor)}→${fmt(blk.per[sym].profitFactor)})`);
}
console.log(`\n blockRanging improved net on ${helped}/${total} symbols.`);
// 2) In-sample vs out-of-sample (split each series at its midpoint).
console.log('\n=== 2) IN-SAMPLE vs OUT-OF-SAMPLE (combined, pessimistic) ===');
const firstHalf = c => c.slice(0, Math.floor(c.length / 2));
const secondHalf = c => c.slice(Math.floor(c.length / 2));
const isBase = run(data, CONFIGS.baseline, 'pessimistic', firstHalf).combined;
const isBlk = run(data, CONFIGS.blockRanging, 'pessimistic', firstHalf).combined;
const oosBase = run(data, CONFIGS.baseline, 'pessimistic', secondHalf).combined;
const oosBlk = run(data, CONFIGS.blockRanging, 'pessimistic', secondHalf).combined;
header('Half / config');
console.log(row('IS baseline', isBase));
console.log(row('IS blockRanging', isBlk));
console.log(row('OOS baseline', oosBase));
console.log(row('OOS blockRanging', oosBlk));
const isWin = isBlk.netPnL > isBase.netPnL;
const oosWin = oosBlk.netPnL > oosBase.netPnL;
console.log(`\n blockRanging wins net: in-sample=${isWin}, out-of-sample=${oosWin}.`);
// 3) Fill sensitivity (combined, full data).
console.log('\n=== 3) FILL SENSITIVITY (combined, full data) ===');
const optBase = run(data, CONFIGS.baseline, 'optimistic').combined;
const optBlk = run(data, CONFIGS.blockRanging, 'optimistic').combined;
header('Fills / config');
console.log(row('pessimistic base', base.combined));
console.log(row('pessimistic block', blk.combined));
console.log(row('optimistic base', optBase));
console.log(row('optimistic block', optBlk));
console.log(`\n blockRanging wins net under: pessimistic=${blk.combined.netPnL > base.combined.netPnL}, optimistic=${optBlk.netPnL > optBase.netPnL}.`);
// Verdict
const robust = (helped >= Math.ceil(total * 0.75)) && isWin && oosWin
&& (blk.combined.netPnL > base.combined.netPnL) && (optBlk.netPnL > optBase.netPnL);
console.log(`\n=== VERDICT ===`);
console.log(robust
? `blockRanging holds across symbols, both data halves, and both fill models. Reasonable to keep live (still validate on longer/cleaner data).`
: `blockRanging is NOT uniformly robust — inspect which dimension failed above before fully trusting it.`);
}
main();
|