e8355b3f62
- Dark mode: class-based theme toggle with localStorage persistence and flash prevention - Trade History (/trades): paginated table, stats cards, equity curve chart with DB API endpoints - Backtest Viewer (/backtests): log parser for 35 backtest results, sidebar + detail + comparison tabs - Model Insights: dashboard card + dialog showing feature importance, regime distribution, training history - Alert/Signal Log (/alerts): signal stats, filterable table with execution tracking - API: 8 new endpoints with psycopg2 DB connection pool - Dark mode sweep across books page, about dialog, and all dashboard components - Architecture docs rewritten with Mermaid diagrams (23 docs) - README and FEATURES.md rewritten bilingual (Indonesian + English) - main_live.py: write model_metrics.json on startup and retrain Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
241 lines
7.7 KiB
JavaScript
241 lines
7.7 KiB
JavaScript
/**
|
|
* Script to generate src/data/backtests.ts from backtest result log files.
|
|
* Scans backtests/*_results/ for the most recent .log file in each directory,
|
|
* parses performance metrics and trade log, and outputs static TS data.
|
|
*
|
|
* Run: node scripts/generate-backtests.js
|
|
*/
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const BACKTESTS_DIR = path.resolve(__dirname, "..", "..", "backtests");
|
|
const OUT = path.join(__dirname, "..", "src", "data", "backtests.ts");
|
|
|
|
function escapeForTemplate(str) {
|
|
return str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
|
}
|
|
|
|
function parseMetric(text, pattern) {
|
|
const m = text.match(pattern);
|
|
return m ? m[1].trim() : null;
|
|
}
|
|
|
|
function parseNumber(text, pattern) {
|
|
const val = parseMetric(text, pattern);
|
|
if (!val) return 0;
|
|
return parseFloat(val.replace(/[,$]/g, "")) || 0;
|
|
}
|
|
|
|
function parsePercent(text, pattern) {
|
|
const val = parseMetric(text, pattern);
|
|
if (!val) return 0;
|
|
return parseFloat(val.replace("%", "")) || 0;
|
|
}
|
|
|
|
function parseExitReasons(text) {
|
|
const section = text.match(/--- EXIT REASON(?:S| BREAKDOWN) ---\n([\s\S]*?)(?=\n---|\n\n\n)/);
|
|
if (!section) return [];
|
|
const lines = section[1].trim().split("\n");
|
|
return lines.map((line) => {
|
|
const m = line.match(/^\s*(\S+)\s*:\s*(\d+)\s*\(\s*([\d.]+)%\)/);
|
|
if (!m) return null;
|
|
return { reason: m[1], count: parseInt(m[2]), pct: parseFloat(m[3]) };
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
function parseDirectionBreakdown(text) {
|
|
const section = text.match(/--- DIRECTION(?:\s+BREAKDOWN)? ---\n([\s\S]*?)(?=\n---|\n\n\n)/);
|
|
if (!section) return [];
|
|
const lines = section[1].trim().split("\n");
|
|
return lines.map((line) => {
|
|
const m = line.match(/^\s*(BUY|SELL):\s*(\d+)\s*trades?,\s*([\d.]+)%\s*WR,\s*\$\s*([-\d,.]+)/);
|
|
if (!m) return null;
|
|
return { direction: m[1], trades: parseInt(m[2]), winRate: parseFloat(m[3]), pnl: parseFloat(m[4].replace(/,/g, "")) };
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
function parseSessionBreakdown(text) {
|
|
const section = text.match(/--- SESSION BREAKDOWN ---\n([\s\S]*?)(?=\n---|\n\n\n)/);
|
|
if (!section) return [];
|
|
const lines = section[1].trim().split("\n");
|
|
return lines.map((line) => {
|
|
const m = line.match(/^\s*(.+?)\s*:\s*(\d+)\s*trades?,\s*([\d.]+)%\s*WR,\s*\$\s*([-\d,.]+)/);
|
|
if (!m) return null;
|
|
return { session: m[1].trim(), trades: parseInt(m[2]), winRate: parseFloat(m[3]), pnl: parseFloat(m[4].replace(/,/g, "")) };
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
function parseTrades(text) {
|
|
const section = text.match(/--- TRADE LOG ---\n.*\n-+\n([\s\S]*?)$/);
|
|
if (!section) return [];
|
|
const lines = section[1].trim().split("\n");
|
|
return lines.slice(0, 500).map((line) => {
|
|
// Format: # date time DIR entry exit P/L result exit_reason conf mode session
|
|
const m = line.match(
|
|
/^\s*(\d+)\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})\s+(BUY|SELL)\s+([\d.]+)\s+([\d.]+)\s+([-\d.]+)\s+(WIN|LOSS)\s+(\S+)\s+(\d+)%\s+(\S+)\s+(.+)$/
|
|
);
|
|
if (!m) return null;
|
|
return {
|
|
num: parseInt(m[1]),
|
|
time: m[2],
|
|
dir: m[3],
|
|
entry: parseFloat(m[4]),
|
|
exit: parseFloat(m[5]),
|
|
pnl: parseFloat(m[6]),
|
|
result: m[7],
|
|
exitReason: m[8],
|
|
conf: parseInt(m[9]),
|
|
mode: m[10],
|
|
session: m[11].trim(),
|
|
};
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
function formatName(dirName) {
|
|
// "01_smc_only_results" -> "SMC Only"
|
|
return dirName
|
|
.replace(/_results$/, "")
|
|
.replace(/^\d+_/, "")
|
|
.split("_")
|
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
// Find all backtest result directories
|
|
const resultDirs = fs
|
|
.readdirSync(BACKTESTS_DIR)
|
|
.filter((d) => d.endsWith("_results") && fs.statSync(path.join(BACKTESTS_DIR, d)).isDirectory())
|
|
.sort();
|
|
|
|
console.log(`Found ${resultDirs.length} backtest result directories`);
|
|
|
|
const results = [];
|
|
|
|
for (const dir of resultDirs) {
|
|
const dirPath = path.join(BACKTESTS_DIR, dir);
|
|
const logFiles = fs
|
|
.readdirSync(dirPath)
|
|
.filter((f) => f.endsWith(".log"))
|
|
.sort()
|
|
.reverse(); // most recent first
|
|
|
|
if (logFiles.length === 0) {
|
|
console.warn(` SKIP ${dir}: no .log files`);
|
|
continue;
|
|
}
|
|
|
|
const logFile = logFiles[0];
|
|
const logPath = path.join(dirPath, logFile);
|
|
const text = fs.readFileSync(logPath, "utf-8");
|
|
|
|
const idMatch = dir.match(/^(\d+)/);
|
|
const id = idMatch ? parseInt(idMatch[1]) : results.length + 1;
|
|
|
|
const result = {
|
|
id,
|
|
slug: dir.replace(/_results$/, ""),
|
|
name: formatName(dir),
|
|
logFile,
|
|
generatedAt: parseMetric(text, /Generated:\s*(.+)/),
|
|
period: parseMetric(text, /Period:\s*(.+)/),
|
|
strategy: parseMetric(text, /Strategy:\s*(.+)/),
|
|
// Support both verbose "Total Trades: 686" and compact "Trades: 683 | WR: 73.4%" formats
|
|
totalTrades: parseNumber(text, /Total Trades:\s*([\d,]+)/) || parseNumber(text, /Trades:\s*([\d,]+)/),
|
|
wins: parseNumber(text, /Wins:\s*([\d,]+)/),
|
|
losses: parseNumber(text, /Losses:\s*([\d,]+)/),
|
|
winRate: parsePercent(text, /Win Rate:\s*([\d.]+)%/) || parsePercent(text, /WR:\s*([\d.]+)%/),
|
|
totalProfit: parseNumber(text, /Total Profit:\s*\$([\d,.]+)/),
|
|
totalLoss: parseNumber(text, /Total Loss:\s*\$([\d,.]+)/),
|
|
netPnl: parseNumber(text, /Net PnL:\s*\$\s*([-\d,.]+)/),
|
|
profitFactor: parseNumber(text, /Profit Factor:\s*([\d.]+)/) || parseNumber(text, /PF:\s*([\d.]+)/),
|
|
maxDrawdown: parsePercent(text, /Max Drawdown:\s*([\d.]+)%/) || parsePercent(text, /Max DD:\s*([\d.]+)%/),
|
|
maxDrawdownUsd: parseNumber(text, /Max Drawdown:\s*[\d.]+%\s*\(\$([\d,.]+)\)/),
|
|
avgWin: parseNumber(text, /Avg Win:\s*\$([\d,.]+)/),
|
|
avgLoss: parseNumber(text, /Avg Loss:\s*\$([\d,.]+)/),
|
|
expectancy: parseNumber(text, /Expectancy:\s*\$([-\d,.]+)/),
|
|
sharpeRatio: parseNumber(text, /Sharpe Ratio:\s*([-\d.]+)/) || parseNumber(text, /Sharpe:\s*([-\d.]+)/),
|
|
exitReasons: parseExitReasons(text),
|
|
directionBreakdown: parseDirectionBreakdown(text),
|
|
sessionBreakdown: parseSessionBreakdown(text),
|
|
tradeCount: 0, // set below
|
|
};
|
|
|
|
// Parse trades (store just count for the static file - trades are big)
|
|
const trades = parseTrades(text);
|
|
result.tradeCount = trades.length;
|
|
|
|
// Fix negative Net PnL (the regex may miss the sign)
|
|
if (text.includes("Net PnL:") && text.match(/Net PnL:\s*-/)) {
|
|
result.netPnl = -Math.abs(result.netPnl);
|
|
}
|
|
|
|
results.push(result);
|
|
console.log(` OK ${dir}: ${result.totalTrades} trades, ${result.winRate}% WR, $${result.netPnl} PnL`);
|
|
}
|
|
|
|
// Sort by id
|
|
results.sort((a, b) => a.id - b.id);
|
|
|
|
// Generate output
|
|
const outDir = path.dirname(OUT);
|
|
if (!fs.existsSync(outDir)) {
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
}
|
|
|
|
let output = `// AUTO-GENERATED — do not edit manually.
|
|
// Run: node scripts/generate-backtests.js
|
|
|
|
export interface ExitReason {
|
|
reason: string;
|
|
count: number;
|
|
pct: number;
|
|
}
|
|
|
|
export interface DirectionBreakdown {
|
|
direction: string;
|
|
trades: number;
|
|
winRate: number;
|
|
pnl: number;
|
|
}
|
|
|
|
export interface SessionBreakdown {
|
|
session: string;
|
|
trades: number;
|
|
winRate: number;
|
|
pnl: number;
|
|
}
|
|
|
|
export interface BacktestResult {
|
|
id: number;
|
|
slug: string;
|
|
name: string;
|
|
logFile: string;
|
|
generatedAt: string | null;
|
|
period: string | null;
|
|
strategy: string | null;
|
|
totalTrades: number;
|
|
wins: number;
|
|
losses: number;
|
|
winRate: number;
|
|
totalProfit: number;
|
|
totalLoss: number;
|
|
netPnl: number;
|
|
profitFactor: number;
|
|
maxDrawdown: number;
|
|
maxDrawdownUsd: number;
|
|
avgWin: number;
|
|
avgLoss: number;
|
|
expectancy: number;
|
|
sharpeRatio: number;
|
|
exitReasons: ExitReason[];
|
|
directionBreakdown: DirectionBreakdown[];
|
|
sessionBreakdown: SessionBreakdown[];
|
|
tradeCount: number;
|
|
}
|
|
|
|
export const backtestResults: BacktestResult[] = ${JSON.stringify(results, null, 2)};
|
|
`;
|
|
|
|
fs.writeFileSync(OUT, output, "utf-8");
|
|
console.log(`\nGenerated ${OUT} with ${results.length} backtest results.`);
|