|
| 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 | + RESEARCH_PARTITIONS, |
| 7 | + filterPartition, |
| 8 | + parseCsvCandles, |
| 9 | + splitContiguousCandles |
| 10 | +} from "./backtest-tools.mjs"; |
| 11 | +import { |
| 12 | + EXIT_TIMING_THRESHOLDS_ATR, |
| 13 | + EXIT_TIMING_WINDOWS_BARS, |
| 14 | + analyzeExitTiming, |
| 15 | + summarizeExitTimingRows |
| 16 | +} from "./exit-timing-tools.mjs"; |
| 17 | + |
| 18 | +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); |
| 19 | +const dataDirectory = join(scriptDirectory, "data"); |
| 20 | +const outputDirectory = join(scriptDirectory, "results"); |
| 21 | +const HOLDOUT_START_MS = Date.parse("2025-01-01T00:00:00.000Z"); |
| 22 | + |
| 23 | +function groupRows(rows, keyFn) { |
| 24 | + const groups = new Map(); |
| 25 | + for (const row of rows) { |
| 26 | + const key = keyFn(row); |
| 27 | + if (!groups.has(key)) groups.set(key, []); |
| 28 | + groups.get(key).push(row); |
| 29 | + } |
| 30 | + return Object.fromEntries( |
| 31 | + [...groups.entries()].map(([key, group]) => [key, summarizeExitTimingRows(group)]) |
| 32 | + ); |
| 33 | +} |
| 34 | + |
| 35 | +async function main() { |
| 36 | + const { runRegimeTrendV1 } = await import( |
| 37 | + pathToFileURL(join(scriptDirectory, "reference-engine.ts")).href |
| 38 | + ); |
| 39 | + const manifest = JSON.parse(await readFile(join(dataDirectory, "dataset-manifest.json"), "utf8")); |
| 40 | + await mkdir(outputDirectory, { recursive: true }); |
| 41 | + |
| 42 | + const report = { |
| 43 | + schema_version: 1, |
| 44 | + strategy_id: "regime-trend-v1", |
| 45 | + generated_at: new Date().toISOString(), |
| 46 | + holdout_opened: false, |
| 47 | + holdout_start: new Date(HOLDOUT_START_MS).toISOString(), |
| 48 | + thresholds_atr: EXIT_TIMING_THRESHOLDS_ATR, |
| 49 | + timing_windows_bars: EXIT_TIMING_WINDOWS_BARS, |
| 50 | + partitions: [] |
| 51 | + }; |
| 52 | + |
| 53 | + for (const partition of RESEARCH_PARTITIONS) { |
| 54 | + if (partition.endExclusive > HOLDOUT_START_MS) { |
| 55 | + throw new Error(`Partition ${partition.id} would open final holdout`); |
| 56 | + } |
| 57 | + |
| 58 | + const rows = []; |
| 59 | + for (const file of manifest.files) { |
| 60 | + const csv = await readFile(join(dataDirectory, file.file), "utf8"); |
| 61 | + if (sha256(csv) !== file.sha256) throw new Error(`SHA-256 mismatch for ${file.symbol}`); |
| 62 | + const candles = filterPartition( |
| 63 | + parseCsvCandles(csv, { endExclusive: HOLDOUT_START_MS }), |
| 64 | + partition |
| 65 | + ); |
| 66 | + |
| 67 | + for (const segment of splitContiguousCandles(candles)) { |
| 68 | + if (segment.length < 201) continue; |
| 69 | + const result = runRegimeTrendV1(segment, { |
| 70 | + datasetHash: file.sha256, |
| 71 | + symbol: file.symbol, |
| 72 | + implementationVersion: "typescript-reference-v1.1.0-exit-timing" |
| 73 | + }); |
| 74 | + for (const trade of result.trades) { |
| 75 | + rows.push({ |
| 76 | + symbol: file.symbol, |
| 77 | + exit_reason: trade.exit_reason, |
| 78 | + winning_trade: trade.net_pnl > 0, |
| 79 | + trade, |
| 80 | + timing: analyzeExitTiming(trade, segment) |
| 81 | + }); |
| 82 | + } |
| 83 | + } |
| 84 | + } |
| 85 | + |
| 86 | + const winners = rows.filter((row) => row.winning_trade); |
| 87 | + const losers = rows.filter((row) => !row.winning_trade); |
| 88 | + const partitionReport = { |
| 89 | + id: partition.id, |
| 90 | + start: new Date(partition.start).toISOString(), |
| 91 | + end_exclusive: new Date(partition.endExclusive).toISOString(), |
| 92 | + all: summarizeExitTimingRows(rows), |
| 93 | + winners: summarizeExitTimingRows(winners), |
| 94 | + losers: summarizeExitTimingRows(losers), |
| 95 | + by_symbol: groupRows(rows, (row) => row.symbol), |
| 96 | + by_exit_reason: groupRows(rows, (row) => row.exit_reason), |
| 97 | + rows |
| 98 | + }; |
| 99 | + report.partitions.push(partitionReport); |
| 100 | + } |
| 101 | + |
| 102 | + const reportPath = join(outputDirectory, "exit-timing-report.json"); |
| 103 | + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); |
| 104 | + |
| 105 | + for (const partition of report.partitions) { |
| 106 | + console.log(`${partition.id}: trades=${partition.all.trades}`); |
| 107 | + for (const threshold of EXIT_TIMING_THRESHOLDS_ATR) { |
| 108 | + const item = partition.losers.thresholds[String(threshold)]; |
| 109 | + console.log( |
| 110 | + ` losers ${threshold.toFixed(2)} ATR: ` + |
| 111 | + `high-touch=${item.high_touch.reached_count}/${partition.losers.trades}, ` + |
| 112 | + `close-confirm=${item.close_confirmation.reached_count}/${partition.losers.trades}, ` + |
| 113 | + `median-high-bars=${item.high_touch.median_bars_to_reach ?? "n/a"}` |
| 114 | + ); |
| 115 | + } |
| 116 | + } |
| 117 | + console.log(`Report written to ${reportPath}`); |
| 118 | + console.log("Final holdout was not opened."); |
| 119 | +} |
| 120 | + |
| 121 | +main().catch((error) => { |
| 122 | + console.error(error instanceof Error ? error.stack ?? error.message : error); |
| 123 | + process.exitCode = 1; |
| 124 | +}); |
0 commit comments