|
| 1 | +import { readFile, mkdir, writeFile } from "node:fs/promises"; |
| 2 | +import { dirname, join } from "node:path"; |
| 3 | +import { fileURLToPath, pathToFileURL } from "node:url"; |
| 4 | +import { sha256 } from "./dataset-tools.mjs"; |
| 5 | +import { |
| 6 | + VALIDATION_START_MS, |
| 7 | + VALIDATION_END_EXCLUSIVE_MS, |
| 8 | + filterPartition, |
| 9 | + parseCsvCandles, |
| 10 | + splitContiguousCandles |
| 11 | +} from "./backtest-tools.mjs"; |
| 12 | +import { |
| 13 | + FORENSIC_HORIZONS, |
| 14 | + analyzePostExit, |
| 15 | + analyzeTradePath, |
| 16 | + summarizeForensicRows |
| 17 | +} from "./forensic-tools.mjs"; |
| 18 | + |
| 19 | +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); |
| 20 | +const dataDirectory = join(scriptDirectory, "data"); |
| 21 | +const outputDirectory = join(scriptDirectory, "results"); |
| 22 | +const HOLDOUT_START_MS = Date.parse("2025-01-01T00:00:00.000Z"); |
| 23 | + |
| 24 | +function quarterId(timestamp) { |
| 25 | + const date = new Date(timestamp); |
| 26 | + return `${date.getUTCFullYear()}-Q${Math.floor(date.getUTCMonth() / 3) + 1}`; |
| 27 | +} |
| 28 | + |
| 29 | +function groupRows(rows, keyFn) { |
| 30 | + const groups = new Map(); |
| 31 | + for (const row of rows) { |
| 32 | + const key = keyFn(row); |
| 33 | + if (!groups.has(key)) groups.set(key, []); |
| 34 | + groups.get(key).push(row); |
| 35 | + } |
| 36 | + return Object.fromEntries( |
| 37 | + [...groups.entries()].map(([key, group]) => [key, summarizeForensicRows(group)]) |
| 38 | + ); |
| 39 | +} |
| 40 | + |
| 41 | +async function main() { |
| 42 | + if (VALIDATION_END_EXCLUSIVE_MS > HOLDOUT_START_MS) { |
| 43 | + throw new Error("Forensic runner would open the final holdout"); |
| 44 | + } |
| 45 | + |
| 46 | + const { runRegimeTrendV1 } = await import( |
| 47 | + pathToFileURL(join(scriptDirectory, "reference-engine.ts")).href |
| 48 | + ); |
| 49 | + const manifest = JSON.parse(await readFile(join(dataDirectory, "dataset-manifest.json"), "utf8")); |
| 50 | + const allRows = []; |
| 51 | + |
| 52 | + for (const file of manifest.files) { |
| 53 | + const csv = await readFile(join(dataDirectory, file.file), "utf8"); |
| 54 | + if (sha256(csv) !== file.sha256) throw new Error(`SHA-256 mismatch for ${file.symbol}`); |
| 55 | + const candles = filterPartition( |
| 56 | + parseCsvCandles(csv, { endExclusive: HOLDOUT_START_MS }), |
| 57 | + { start: VALIDATION_START_MS, endExclusive: VALIDATION_END_EXCLUSIVE_MS } |
| 58 | + ); |
| 59 | + |
| 60 | + for (const segment of splitContiguousCandles(candles)) { |
| 61 | + if (segment.length < 201) continue; |
| 62 | + const result = runRegimeTrendV1(segment, { |
| 63 | + datasetHash: file.sha256, |
| 64 | + symbol: file.symbol, |
| 65 | + implementationVersion: "typescript-reference-v1.1.0-forensic" |
| 66 | + }); |
| 67 | + |
| 68 | + for (const trade of result.trades) { |
| 69 | + const path = analyzeTradePath(trade, segment); |
| 70 | + allRows.push({ |
| 71 | + symbol: file.symbol, |
| 72 | + quarter: quarterId(trade.exit_timestamp), |
| 73 | + exit_reason: trade.exit_reason, |
| 74 | + losing_trade: trade.net_pnl < 0, |
| 75 | + trade, |
| 76 | + path, |
| 77 | + counterfactuals: trade.net_pnl < 0 |
| 78 | + ? analyzePostExit(trade, segment, FORENSIC_HORIZONS) |
| 79 | + : {} |
| 80 | + }); |
| 81 | + } |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + const losingRows = allRows.filter((row) => row.losing_trade); |
| 86 | + const winners = allRows.filter((row) => !row.losing_trade); |
| 87 | + const winnerCapture = winners.map((row) => row.path.capture_ratio).filter(Number.isFinite); |
| 88 | + const loserCapture = losingRows.map((row) => row.path.capture_ratio).filter(Number.isFinite); |
| 89 | + |
| 90 | + const report = { |
| 91 | + schema_version: 1, |
| 92 | + strategy_id: "regime-trend-v1", |
| 93 | + generated_at: new Date().toISOString(), |
| 94 | + holdout_opened: false, |
| 95 | + validation_start: new Date(VALIDATION_START_MS).toISOString(), |
| 96 | + validation_end_exclusive: new Date(VALIDATION_END_EXCLUSIVE_MS).toISOString(), |
| 97 | + horizons_bars: FORENSIC_HORIZONS, |
| 98 | + totals: { |
| 99 | + closed_trades: allRows.length, |
| 100 | + losing_trades: losingRows.length, |
| 101 | + winning_trades: winners.length, |
| 102 | + losing_trades_with_mfe_at_least_1_atr: losingRows.filter((row) => row.path.mfe_atr >= 1).length, |
| 103 | + losing_trades_with_mfe_below_0_5_atr: losingRows.filter((row) => row.path.mfe_atr < 0.5).length, |
| 104 | + average_winner_capture_ratio: winnerCapture.length |
| 105 | + ? winnerCapture.reduce((sum, value) => sum + value, 0) / winnerCapture.length |
| 106 | + : null, |
| 107 | + average_loser_capture_ratio: loserCapture.length |
| 108 | + ? loserCapture.reduce((sum, value) => sum + value, 0) / loserCapture.length |
| 109 | + : null |
| 110 | + }, |
| 111 | + losing_trade_summary: summarizeForensicRows(losingRows), |
| 112 | + by_symbol: groupRows(losingRows, (row) => row.symbol), |
| 113 | + by_exit_reason: groupRows(losingRows, (row) => row.exit_reason), |
| 114 | + by_quarter: groupRows(losingRows, (row) => row.quarter), |
| 115 | + rows: allRows |
| 116 | + }; |
| 117 | + |
| 118 | + await mkdir(outputDirectory, { recursive: true }); |
| 119 | + const reportPath = join(outputDirectory, "forensic-diagnostics-report.json"); |
| 120 | + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); |
| 121 | + |
| 122 | + console.log(`closed trades: ${report.totals.closed_trades}`); |
| 123 | + console.log(`losing trades: ${report.totals.losing_trades}`); |
| 124 | + console.log( |
| 125 | + `losers with >=1 ATR MFE before loss: ${report.totals.losing_trades_with_mfe_at_least_1_atr}` |
| 126 | + ); |
| 127 | + for (const horizon of FORENSIC_HORIZONS) { |
| 128 | + const item = report.losing_trade_summary[horizon]; |
| 129 | + console.log( |
| 130 | + `${horizon} bars: short=${item.short_reversal_count}/${item.eligible}, ` + |
| 131 | + `recovery=${item.long_recovery_count}/${item.eligible}, ` + |
| 132 | + `no-trade=${item.no_trade_count}/${item.eligible}` |
| 133 | + ); |
| 134 | + } |
| 135 | + console.log(`Report written to ${reportPath}`); |
| 136 | + console.log("Final holdout was not opened."); |
| 137 | +} |
| 138 | + |
| 139 | +main().catch((error) => { |
| 140 | + console.error(error instanceof Error ? error.stack ?? error.message : error); |
| 141 | + process.exitCode = 1; |
| 142 | +}); |
0 commit comments