Skip to content

Commit a634241

Browse files
committed
feat: add Regime Trend forensic diagnostics runner
1 parent 13d739f commit a634241

1 file changed

Lines changed: 142 additions & 0 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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

Comments
 (0)