v1.2.0: 新增量化策略模块(6主流策略全市场回测+单股净值曲线/交易明细),策略: 双均线/MACD/RSI/布林带/动量/N日突破

This commit is contained in:
2026-08-19 21:09:16 +08:00
parent 4a9ea9ddd7
commit c05b1d54b3
9 changed files with 713 additions and 3 deletions
+148
View File
@@ -0,0 +1,148 @@
/* 量化策略回测页 */
let curStrategy = 'ma_cross';
let rankData = [];
let strategiesData = [];
let chart = null;
async function loadStrategies() {
try {
const d = await api('/api/strategies');
strategiesData = d.items || [];
$('#strategyPills').innerHTML = strategiesData.map((s, i) =>
`<button class="pill ${i === 0 ? 'active' : ''}" data-k="${s.key}">${s.icon} ${s.name} <span style="color:var(--muted);font-size:11px">${s.params}</span></button>`
).join('');
$$('#strategyPills .pill').forEach(p => p.onclick = () => {
const cfg = strategiesData.find(s => s.key === p.dataset.k);
selectStrategy(p.dataset.k, cfg);
});
selectStrategy(strategiesData[0].key, strategiesData[0]);
} catch (e) {
$('#strategyPills').innerHTML = '<div class="empty">加载失败</div>';
}
}
function selectStrategy(key, cfg) {
curStrategy = key;
$$('#strategyPills .pill').forEach(p => p.classList.toggle('active', p.dataset.k === key));
if (cfg) renderDesc(cfg);
loadMarket(key);
}
function renderDesc(cfg) {
$('#stratDesc').innerHTML = `<b style="color:var(--text)">${cfg.icon} ${cfg.name}${cfg.params}</b>${escapeHtml(cfg.desc)} <span class="tag">${cfg.tags.join(' / ')}</span>`;
const s = cfg.summary || {};
$('#stratSummary').innerHTML = `
<div class="kpi"><div class="kpi-label">全市场平均收益</div><div class="kpi-value num ${s.avg_return >= 0 ? 'up' : 'down'}">${fmtPct(s.avg_return)}</div></div>
<div class="kpi"><div class="kpi-label">平均夏普</div><div class="kpi-value num">${s.avg_sharpe ?? '--'}</div></div>
<div class="kpi"><div class="kpi-label">平均胜率</div><div class="kpi-value num">${s.avg_win_rate ?? '--'}%</div></div>
<div class="kpi"><div class="kpi-label">覆盖股票</div><div class="kpi-value num">${s.n ?? '--'}</div></div>
<div class="kpi"><div class="kpi-label">最优标的</div><div class="kpi-value" style="font-size:15px">${s.best_name ? `<a href="/stock/${s.best_code}" target="_blank">${s.best_name}</a> <span class="num up">${fmtPct(s.best_return)}</span>` : '--'}</div></div>`;
}
async function loadMarket(key) {
$('#rankTb').innerHTML = '<tr><td colspan="10" class="loading">加载中…</td></tr>';
try {
const d = await api('/api/backtest/market?strategy=' + key);
rankData = d.items || [];
renderRank();
// 默认选中第一名股票
if (rankData.length) loadDetail(rankData[0].code);
else $('#rankTb').innerHTML = '<tr><td colspan="10" class="empty">暂无回测数据,请到数据管理页重建</td></tr>';
} catch (e) {
$('#rankTb').innerHTML = '<tr><td colspan="10" class="empty">加载失败</td></tr>';
}
}
function renderRank() {
$('#rankHint').textContent = `${rankData.length} 只股票)`;
$('#rankTb').innerHTML = rankData.map((it, i) => `
<tr class="row-link" onclick="loadDetail('${it.code}')">
<td class="num">${i + 1}</td>
<td><b>${it.name}</b><div style="color:var(--muted);font-size:12px">${it.code}</div></td>
<td class="num ${pctClass(it.total_return)}"><b>${fmtPct(it.total_return)}</b></td>
<td class="num ${pctClass(it.benchmark)}">${fmtPct(it.benchmark)}</td>
<td class="num ${pctClass(it.excess)}">${fmtPct(it.excess)}</td>
<td class="num down">${fmtPct(it.max_drawdown)}</td>
<td class="num ${it.sharpe >= 0 ? 'up' : 'down'}">${it.sharpe}</td>
<td class="num">${it.win_rate}%</td>
<td class="num">${it.profit_factor}</td>
<td class="num">${it.trades}</td>
</tr>`).join('');
// 同步下拉框
fillStockSelect();
}
function fillStockSelect() {
const sel = $('#btStock');
sel.innerHTML = rankData.map(it => `<option value="${it.code}">${it.name}${it.code}</option>`).join('');
sel.onchange = () => loadDetail(sel.value);
}
async function loadDetail(code) {
const sel = $('#btStock');
if (sel.value !== code) sel.value = code;
try {
const d = await api(`/api/backtest?strategy=${curStrategy}&code=${code}`);
renderDetail(d);
} catch (e) {
$('#btHead').innerHTML = '<div class="empty">加载失败</div>';
}
}
function renderDetail(d) {
const m = d.metrics, st = d.stock || {};
$('#btHead').innerHTML = `
<div class="flex between wrap">
<div>
<b style="font-size:16px">${d.stock_name}</b> <span style="color:var(--muted)">${d.code} · ${st.industry || ''} · ${st.board || ''}</span>
</div>
<span style="color:var(--muted);font-size:12px">${d.config.icon} ${d.config.name} · 回测 ${m.days} 个交易日</span>
</div>`;
$('#btMetrics').innerHTML = `
<div class="kpi"><div class="kpi-label">策略收益</div><div class="kpi-value num ${pctClass(m.total_return)}">${fmtPct(m.total_return)}</div></div>
<div class="kpi"><div class="kpi-label">年化收益</div><div class="kpi-value num ${pctClass(m.annualized)}">${fmtPct(m.annualized)}</div></div>
<div class="kpi"><div class="kpi-label">最大回撤</div><div class="kpi-value num down">${fmtPct(m.max_drawdown)}</div></div>
<div class="kpi"><div class="kpi-label">夏普比率</div><div class="kpi-value num ${m.sharpe >= 0 ? 'up' : 'down'}">${m.sharpe}</div></div>
<div class="kpi"><div class="kpi-label">胜率</div><div class="kpi-value num">${m.win_rate}%</div></div>
<div class="kpi"><div class="kpi-label">盈亏比</div><div class="kpi-value num">${m.profit_factor}</div></div>
<div class="kpi"><div class="kpi-label">交易次数</div><div class="kpi-value num">${m.trades}</div></div>
<div class="kpi"><div class="kpi-label">买入持有</div><div class="kpi-value num ${pctClass(m.benchmark)}">${fmtPct(m.benchmark)}</div></div>
<div class="kpi"><div class="kpi-label">超额收益</div><div class="kpi-value num ${pctClass(m.excess)}">${fmtPct(m.excess)}</div></div>`;
renderEquity(d.equity);
renderTrades(d.trades);
}
function renderEquity(equity) {
const el = $('#equityChart');
if (!window.echarts) { el.innerHTML = '<div class="empty">ECharts 加载失败</div>'; return; }
if (!chart) chart = echarts.init(el);
chart.setOption({
backgroundColor: 'transparent', animation: false,
tooltip: { trigger: 'axis', valueFormatter: v => (v * 100).toFixed(1) + '%' },
legend: { data: ['策略净值', '买入持有'], textStyle: { color: '#9da7b3' }, top: 0 },
grid: { left: 50, right: 14, top: 28, bottom: 24 },
xAxis: { type: 'category', data: equity.map(e => e.date), axisLabel: { color: '#6e7681' }, axisLine: { lineStyle: { color: '#262d3a' } } },
yAxis: { type: 'value', name: '净值', scale: true, splitLine: { lineStyle: { color: '#1f2630' } }, axisLabel: { color: '#6e7681', formatter: v => (v * 100).toFixed(0) + '%' } },
series: [
{ name: '策略净值', type: 'line', data: equity.map(e => e.value), showSymbol: false, lineStyle: { width: 2, color: '#f59e0b' }, areaStyle: { color: 'rgba(245,158,11,.08)' } },
{ name: '买入持有', type: 'line', data: equity.map(e => e.bh), showSymbol: false, lineStyle: { width: 1.5, color: '#3b82f6', type: 'dashed' } },
],
}, true);
}
function renderTrades(trades) {
if (!trades.length) { $('#tradeBox').innerHTML = '<div class="empty">该策略在回测期内无交易信号</div>'; return; }
$('#tradeBox').innerHTML = `<table>
<tr><th>买入日期</th><th>买入价</th><th>卖出日期</th><th>卖出价</th><th>收益</th><th>持有(天)</th><th>信号</th></tr>
${trades.map(t => `<tr>
<td>${t.entry_date}</td><td class="num">${t.entry_price}</td>
<td>${t.exit_date}</td><td class="num">${t.exit_price}</td>
<td class="num ${pctClass(t.return)}"><b>${fmtPct(t.return)}</b></td>
<td class="num">${t.days}</td>
<td style="max-width:200px;white-space:normal;font-size:12px;color:var(--text2)">${escapeHtml(t.reason)}</td>
</tr>`).join('')}
</table>`;
}
window.addEventListener('resize', () => chart && chart.resize());
loadStrategies();