149 lines
7.8 KiB
JavaScript
149 lines
7.8 KiB
JavaScript
/* 量化策略回测页 */
|
||
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();
|