feat: 策略实验室多版本体系 — v1~v4.1迭代+12维上下文(大盘/趋势变化)+因子归因+版本对比列表
This commit is contained in:
+138
-66
@@ -1929,84 +1929,156 @@ function inMd(t) {
|
||||
function renderResearch() {
|
||||
const el = document.getElementById('tab-research');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="flex gap-2 items-center mb-4"><h2 class="text-lg font-bold">📋 策略研究 — 全流程回测</h2>' +
|
||||
'<select id="btPeriod" class="bg-slate-800 text-sm rounded-lg px-2 py-1 border border-slate-700" onchange="runBacktest()">' +
|
||||
'<option value="1m">近 1 个月</option>' +
|
||||
'<option value="6m" selected>近 6 个月</option>' +
|
||||
'<option value="1y">近 1 年</option>' +
|
||||
'<option value="2y">近 2 年</option>' +
|
||||
'</select>' +
|
||||
'<button onclick="runBacktest()" class="px-3 py-1 bg-blue-600 hover:bg-blue-500 rounded-lg text-sm">▶ 运行</button></div>' +
|
||||
'<div id="btResults" class="text-slate-400 text-sm">点击「运行」开始回测验证</div>';
|
||||
window.btEl = el;
|
||||
el.innerHTML = '<div class="flex gap-2 items-center mb-3 flex-wrap">' +
|
||||
'<h2 class="text-lg font-bold">📋 策略研究 — 版本迭代对比</h2>' +
|
||||
'<select id="btPeriod" class="bg-slate-800 text-sm rounded-lg px-2 py-1 border border-slate-700">' +
|
||||
'<option value="1m">近 1 个月</option><option value="6m" selected>近 6 个月</option>' +
|
||||
'<option value="1y">近 1 年</option></select>' +
|
||||
'<span class="text-xs text-slate-500">每版策略基于上一版归因分析迭代 · 点击行展开明细</span></div>' +
|
||||
'<div id="strategyList"><div class="text-slate-500 text-sm">加载中...</div></div>' +
|
||||
'<div id="strategyDetail" class="mt-4"></div>';
|
||||
loadStrategyList();
|
||||
}
|
||||
|
||||
async function runBacktest() {
|
||||
const period = document.getElementById('btPeriod')?.value || '6m';
|
||||
const resultsEl = document.getElementById('btResults');
|
||||
if (!resultsEl) return;
|
||||
resultsEl.innerHTML = '<div class="text-blue-400 animate-pulse">⏳ 回测运行中(约 15-30 秒)...</div>';
|
||||
async function loadStrategyList() {
|
||||
const listEl = document.getElementById('strategyList');
|
||||
try {
|
||||
const resp = await fetch('/api/research/backtest?period=' + period);
|
||||
const resp = await fetch('/api/research/strategies');
|
||||
const data = await resp.json();
|
||||
if (data.error) { resultsEl.innerHTML = '<div class="text-red-400">错误: ' + data.error + '</div>'; return; }
|
||||
displayBacktestResults(data);
|
||||
if (data.error) { listEl.innerHTML = '<div class="text-red-400">' + data.error + '</div>'; return; }
|
||||
renderStrategyTable(data.strategies || []);
|
||||
} catch(e) {
|
||||
resultsEl.innerHTML = '<div class="text-red-400">请求失败: ' + e.message + '</div>';
|
||||
listEl.innerHTML = '<div class="text-red-400">加载失败: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function displayBacktestResults(data) {
|
||||
const el = document.getElementById('btResults');
|
||||
if (!el) return;
|
||||
const s = data.summary || {};
|
||||
const winRate = s.win_rate || 0;
|
||||
const winRateClass = winRate >= 50 ? 'text-green-400' : winRate >= 40 ? 'text-yellow-400' : 'text-red-400';
|
||||
const sharpeClass = (s.sharpe_ratio || 0) >= 1 ? 'text-green-400' : (s.sharpe_ratio || 0) >= 0 ? 'text-yellow-400' : 'text-red-400';
|
||||
|
||||
let html = '<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">' +
|
||||
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">测试周期</div><div class="text-sm font-mono">' + (data.period || '') + '</div></div>' +
|
||||
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">初始资金</div><div class="text-sm font-mono">¥' + (s.capital_end ? (data.capital || 1000000).toLocaleString() : '100万') + '</div></div>' +
|
||||
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">最终资金</div><div class="text-sm font-mono">¥' + (s.capital_end ? s.capital_end.toLocaleString() : '-') + '</div></div>' +
|
||||
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">总交易</div><div class="text-sm font-mono">' + (s.total_trades || 0) + ' 笔</div></div>' +
|
||||
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">胜率</div><div class="text-lg font-bold ' + winRateClass + '">' + s.win_rate + '%</div></div>' +
|
||||
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">平均收益</div><div class="text-lg font-bold ' + ((s.avg_profit_pct || 0) >= 0 ? 'text-green-400' : 'text-red-400') + '">' + (s.avg_profit_pct || 0) + '%</div></div>' +
|
||||
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">夏普比率</div><div class="text-lg font-bold ' + sharpeClass + '">' + (s.sharpe_ratio || 0).toFixed(2) + '</div></div>' +
|
||||
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">最大回撤</div><div class="text-lg font-bold text-red-400">' + (s.max_drawdown_pct || 0).toFixed(1) + '%</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="grid grid-cols-4 gap-2 mb-4 text-xs">' +
|
||||
'<div class="bg-green-900/20 rounded p-2"><span class="text-green-400">✅ 盈利: ' + (s.wins || 0) + '</span> <span class="text-slate-500">均+' + (s.avg_win_pct || 0).toFixed(1) + '%</span></div>' +
|
||||
'<div class="bg-red-900/20 rounded p-2"><span class="text-red-400">❌ 亏损: ' + (s.losses || 0) + '</span> <span class="text-slate-500">均' + (s.avg_loss_pct || 0).toFixed(1) + '%</span></div>' +
|
||||
'<div class="bg-slate-800/30 rounded p-2"><span class="text-yellow-400">📊 盈亏比: ' + (s.profit_factor || 0).toFixed(2) + '</span></div>' +
|
||||
'<div class="bg-slate-800/30 rounded p-2"><span class="text-slate-300">📈 筛选: ' + (data.total_stocks_screened || 0) + ' 只</span></div>' +
|
||||
'</div>';
|
||||
|
||||
// Trade list
|
||||
const trades = data.trades || [];
|
||||
html += '<div class="mb-2 text-sm font-bold text-slate-300">交易明细(前 50 笔)</div>' +
|
||||
'<div class="overflow-x-auto"><table class="w-full text-xs"><thead><tr class="text-slate-500 border-b border-slate-700">' +
|
||||
'<th class="text-left px-2 py-1">代码</th><th class="text-left px-2 py-1">名称</th><th class="text-left px-2 py-1">买入日</th>' +
|
||||
'<th class="text-right px-2 py-1">买入价</th><th class="text-right px-2 py-1">卖出价</th><th class="text-right px-2 py-1">收益</th>' +
|
||||
'<th class="text-center px-2 py-1">结果</th><th class="text-right px-2 py-1">持仓</th><th class="text-right px-2 py-1">评分</th></tr></thead><tbody>';
|
||||
for (const t of trades) {
|
||||
const pnlClass = (t.profit_pct || 0) >= 0 ? 'text-green-400' : 'text-red-400';
|
||||
const reasonMap = { 'target': '🎯 止盈', 'stop': '🛑 止损', 'keep': '⏳ 持仓中' };
|
||||
html += '<tr class="border-b border-slate-800/50 hover:bg-slate-800/30">' +
|
||||
'<td class="px-2 py-1 font-mono">' + (t.code || '') + '</td>' +
|
||||
'<td class="px-2 py-1">' + (t.name || '') + '</td>' +
|
||||
'<td class="px-2 py-1 font-mono">' + (t.entry_date || '') + '</td>' +
|
||||
'<td class="text-right px-2 py-1 font-mono">' + (t.entry_price || 0).toFixed(2) + '</td>' +
|
||||
'<td class="text-right px-2 py-1 font-mono">' + (t.exit_price || 0).toFixed(2) + '</td>' +
|
||||
'<td class="text-right px-2 py-1 font-mono ' + pnlClass + '">' + (t.profit_pct || 0).toFixed(1) + '%</td>' +
|
||||
'<td class="text-center px-2 py-1">' + (reasonMap[t.exit_reason] || t.exit_reason || '') + '</td>' +
|
||||
'<td class="text-right px-2 py-1">' + (t.hold_days || 0) + 'd</td>' +
|
||||
'<td class="text-right px-2 py-1">' + (t.score || 0) + '</td></tr>';
|
||||
function renderStrategyTable(strategies) {
|
||||
const el = document.getElementById('strategyList');
|
||||
if (!strategies.length) { el.innerHTML = '<div class="text-slate-500">暂无策略版本</div>'; return; }
|
||||
// 找每列最优值用于高亮
|
||||
const best = { win_rate: -999, sharpe_ratio: -999, profit_factor: -999, avg_profit_pct: -999, max_drawdown_pct: 999 };
|
||||
for (const s of strategies) {
|
||||
const st = s.summary_stats || {};
|
||||
if (st.win_rate != null) best.win_rate = Math.max(best.win_rate, st.win_rate);
|
||||
if (st.sharpe_ratio != null) best.sharpe_ratio = Math.max(best.sharpe_ratio, st.sharpe_ratio);
|
||||
if (st.profit_factor != null) best.profit_factor = Math.max(best.profit_factor, st.profit_factor);
|
||||
if (st.avg_profit_pct != null) best.avg_profit_pct = Math.max(best.avg_profit_pct, st.avg_profit_pct);
|
||||
if (st.max_drawdown_pct != null) best.max_drawdown_pct = Math.min(best.max_drawdown_pct, st.max_drawdown_pct);
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
|
||||
const hl = (val, bestVal, invert) => {
|
||||
if (val == null) return '';
|
||||
const isBest = invert ? (val === bestVal && bestVal !== 999) : (val === bestVal && bestVal !== -999);
|
||||
return isBest ? 'text-green-400 font-bold' : '';
|
||||
};
|
||||
let html = '<table class="w-full text-xs"><thead><tr class="text-slate-500 border-b border-slate-700">' +
|
||||
'<th class="text-left px-2 py-1.5">版本</th><th class="text-left px-2 py-1.5">名称</th>' +
|
||||
'<th class="text-left px-2 py-1.5">改进假设</th>' +
|
||||
'<th class="text-right px-2 py-1.5">交易数</th><th class="text-right px-2 py-1.5">胜率</th>' +
|
||||
'<th class="text-right px-2 py-1.5">平均收益</th><th class="text-right px-2 py-1.5">夏普</th>' +
|
||||
'<th class="text-right px-2 py-1.5">盈亏比</th><th class="text-right px-2 py-1.5">最大回撤</th>' +
|
||||
'<th class="text-center px-2 py-1.5">操作</th></tr></thead><tbody>';
|
||||
for (const s of strategies) {
|
||||
const st = s.summary_stats || {};
|
||||
const hasResult = st.total_trades != null;
|
||||
const smallSample = hasResult && st.total_trades < 40;
|
||||
html += '<tr class="border-b border-slate-800/50 hover:bg-slate-800/30 cursor-pointer" onclick="showStrategyDetail(\'' + s.version + '\')">' +
|
||||
'<td class="px-2 py-1.5 font-mono font-bold text-blue-400">' + s.version + '</td>' +
|
||||
'<td class="px-2 py-1.5">' + (s.name || '') + '</td>' +
|
||||
'<td class="px-2 py-1.5 text-slate-400 max-w-xs truncate" title="' + (s.hypothesis || '').replace(/"/g, '"') + '">' + (s.hypothesis || s.summary || '') + '</td>' +
|
||||
'<td class="text-right px-2 py-1.5 font-mono">' + (hasResult ? st.total_trades + (smallSample ? '⚠️' : '') : '—') + '</td>' +
|
||||
'<td class="text-right px-2 py-1.5 font-mono ' + hl(st.win_rate, best.win_rate) + '">' + (st.win_rate != null ? st.win_rate + '%' : '—') + '</td>' +
|
||||
'<td class="text-right px-2 py-1.5 font-mono ' + ((st.avg_profit_pct || 0) >= 0 ? 'text-green-400' : 'text-red-400') + '">' + (st.avg_profit_pct != null ? st.avg_profit_pct + '%' : '—') + '</td>' +
|
||||
'<td class="text-right px-2 py-1.5 font-mono ' + hl(st.sharpe_ratio, best.sharpe_ratio) + '">' + (st.sharpe_ratio != null ? st.sharpe_ratio : '—') + '</td>' +
|
||||
'<td class="text-right px-2 py-1.5 font-mono ' + hl(st.profit_factor, best.profit_factor) + '">' + (st.profit_factor != null ? st.profit_factor : '—') + '</td>' +
|
||||
'<td class="text-right px-2 py-1.5 font-mono ' + hl(st.max_drawdown_pct, best.max_drawdown_pct, true) + '">' + (st.max_drawdown_pct != null ? st.max_drawdown_pct + '%' : '—') + '</td>' +
|
||||
'<td class="text-center px-2 py-1.5" onclick="event.stopPropagation()">' +
|
||||
'<button onclick="runStrategyBacktest(\'' + s.version + '\')" class="px-2 py-0.5 bg-blue-600/60 hover:bg-blue-500 rounded text-xs">▶回测</button>' +
|
||||
'</td></tr>';
|
||||
}
|
||||
html += '</tbody></table>' +
|
||||
'<div class="text-xs text-slate-600 mt-2">⚠️ = 样本量<40笔,统计意义有限 · 绿色 = 该列最优 · 回测期间: 2026-01-21 ~ 2026-07-24</div>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
async function runStrategyBacktest(version) {
|
||||
const period = document.getElementById('btPeriod')?.value || '6m';
|
||||
const detail = document.getElementById('strategyDetail');
|
||||
detail.innerHTML = '<div class="text-blue-400 animate-pulse text-sm">⏳ 正在回测 ' + version + '(约15-30秒)...</div>';
|
||||
try {
|
||||
const resp = await fetch('/api/research/backtest?strategy=' + version + '&period=' + period);
|
||||
const data = await resp.json();
|
||||
if (data.error) { detail.innerHTML = '<div class="text-red-400">' + data.error + '</div>'; return; }
|
||||
await loadStrategyList();
|
||||
showStrategyResult(data);
|
||||
} catch(e) {
|
||||
detail.innerHTML = '<div class="text-red-400">失败: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function showStrategyDetail(version) {
|
||||
const detail = document.getElementById('strategyDetail');
|
||||
detail.innerHTML = '<div class="text-slate-500 text-sm">加载 ' + version + ' 明细...</div>';
|
||||
try {
|
||||
const [anaResp, tradesResp] = await Promise.all([
|
||||
fetch('/api/research/analysis?strategy=' + version),
|
||||
fetch('/api/research/trades?strategy=' + version)
|
||||
]);
|
||||
const ana = await anaResp.json();
|
||||
const tradesData = await tradesResp.json();
|
||||
showStrategyResult({ strategy: version, trades: tradesData.trades || [], insights: (ana.insights || []), analysis: ana });
|
||||
} catch(e) {
|
||||
detail.innerHTML = '<div class="text-red-400">失败: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function showStrategyResult(data) {
|
||||
const el = document.getElementById('strategyDetail');
|
||||
const s = data.summary || {};
|
||||
let html = '<div class="border border-slate-700 rounded-xl p-4 bg-slate-900/40">' +
|
||||
'<div class="flex items-center gap-3 mb-3"><h3 class="font-bold text-blue-300">' + (data.strategy || '') + ' ' + (data.strategy_name || '') + '</h3>' +
|
||||
'<span class="text-xs text-slate-500">' + (data.period || '') + '</span></div>';
|
||||
if (s.total_trades != null) {
|
||||
html += '<div class="grid grid-cols-3 md:grid-cols-6 gap-2 mb-3 text-center">' +
|
||||
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">交易</div><div class="font-mono">' + s.total_trades + '</div></div>' +
|
||||
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">胜率</div><div class="font-mono ' + (s.win_rate >= 50 ? 'text-green-400' : 'text-yellow-400') + '">' + s.win_rate + '%</div></div>' +
|
||||
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">平均收益</div><div class="font-mono ' + (s.avg_profit_pct >= 0 ? 'text-green-400' : 'text-red-400') + '">' + s.avg_profit_pct + '%</div></div>' +
|
||||
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">夏普</div><div class="font-mono">' + s.sharpe_ratio + '</div></div>' +
|
||||
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">盈亏比</div><div class="font-mono">' + (s.profit_factor || '—') + '</div></div>' +
|
||||
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">回撤</div><div class="font-mono text-red-400">' + s.max_drawdown_pct + '%</div></div></div>';
|
||||
}
|
||||
// 归因洞察
|
||||
if (data.insights && data.insights.length) {
|
||||
html += '<div class="mb-3"><div class="text-sm font-bold text-slate-300 mb-1">🔬 因子归因洞察(下一版迭代依据)</div>' +
|
||||
'<div class="space-y-1">' + data.insights.map(i => '<div class="text-xs text-slate-400 bg-slate-800/30 rounded px-2 py-1">' + i + '</div>').join('') + '</div></div>';
|
||||
}
|
||||
// 交易明细
|
||||
const trades = (data.trades || []).slice(0, 50);
|
||||
if (trades.length) {
|
||||
html += '<div class="text-sm font-bold text-slate-300 mb-1">交易明细(前50笔)</div>' +
|
||||
'<div class="overflow-x-auto max-h-96 overflow-y-auto"><table class="w-full text-xs"><thead class="sticky top-0 bg-slate-900"><tr class="text-slate-500 border-b border-slate-700">' +
|
||||
'<th class="text-left px-2 py-1">代码</th><th class="text-left px-2 py-1">名称</th><th class="text-left px-2 py-1">入场</th>' +
|
||||
'<th class="text-right px-2 py-1">买入</th><th class="text-right px-2 py-1">卖出</th><th class="text-right px-2 py-1">收益</th>' +
|
||||
'<th class="text-center px-2 py-1">结果</th><th class="text-right px-2 py-1">持仓</th><th class="text-right px-2 py-1">评分</th></tr></thead><tbody>';
|
||||
const reasonMap = { target: '🎯止盈', stop: '🛑止损', keep: '⏳到期' };
|
||||
for (const t of trades) {
|
||||
const pc = (t.profit_pct || 0) >= 0 ? 'text-green-400' : 'text-red-400';
|
||||
html += '<tr class="border-b border-slate-800/40">' +
|
||||
'<td class="px-2 py-1 font-mono">' + t.code + '</td><td class="px-2 py-1">' + (t.name || '') + '</td>' +
|
||||
'<td class="px-2 py-1 font-mono">' + (t.entry_date || '') + '</td>' +
|
||||
'<td class="text-right px-2 py-1 font-mono">' + (t.entry_price || 0).toFixed(2) + '</td>' +
|
||||
'<td class="text-right px-2 py-1 font-mono">' + (t.exit_price || 0).toFixed(2) + '</td>' +
|
||||
'<td class="text-right px-2 py-1 font-mono ' + pc + '">' + (t.profit_pct || 0).toFixed(1) + '%</td>' +
|
||||
'<td class="text-center px-2 py-1">' + (reasonMap[t.exit_reason] || t.exit_reason) + '</td>' +
|
||||
'<td class="text-right px-2 py-1">' + (t.hold_days || 0) + 'd</td>' +
|
||||
'<td class="text-right px-2 py-1">' + (t.score || 0) + '</td></tr>';
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user