/* 量化策略回测页 */
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) =>
``
).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 = '
加载失败
';
}
}
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 = `${cfg.icon} ${cfg.name}(${cfg.params}):${escapeHtml(cfg.desc)} ${cfg.tags.join(' / ')}`;
const s = cfg.summary || {};
$('#stratSummary').innerHTML = `
全市场平均收益
${fmtPct(s.avg_return)}
平均夏普
${s.avg_sharpe ?? '--'}
平均胜率
${s.avg_win_rate ?? '--'}%
`;
}
async function loadMarket(key) {
$('#rankTb').innerHTML = '| 加载中… |
';
try {
const d = await api('/api/backtest/market?strategy=' + key);
rankData = d.items || [];
renderRank();
// 默认选中第一名股票
if (rankData.length) loadDetail(rankData[0].code);
else $('#rankTb').innerHTML = '| 暂无回测数据,请到数据管理页重建 |
';
} catch (e) {
$('#rankTb').innerHTML = '| 加载失败 |
';
}
}
function renderRank() {
$('#rankHint').textContent = `(${rankData.length} 只股票)`;
$('#rankTb').innerHTML = rankData.map((it, i) => `
| ${i + 1} |
${it.name} ${it.code} |
${fmtPct(it.total_return)} |
${fmtPct(it.benchmark)} |
${fmtPct(it.excess)} |
${fmtPct(it.max_drawdown)} |
${it.sharpe} |
${it.win_rate}% |
${it.profit_factor} |
${it.trades} |
`).join('');
// 同步下拉框
fillStockSelect();
}
function fillStockSelect() {
const sel = $('#btStock');
sel.innerHTML = rankData.map(it => ``).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 = '加载失败
';
}
}
function renderDetail(d) {
const m = d.metrics, st = d.stock || {};
$('#btHead').innerHTML = `
${d.stock_name} ${d.code} · ${st.industry || ''} · ${st.board || ''}
${d.config.icon} ${d.config.name} · 回测 ${m.days} 个交易日
`;
$('#btMetrics').innerHTML = `
策略收益
${fmtPct(m.total_return)}
年化收益
${fmtPct(m.annualized)}
最大回撤
${fmtPct(m.max_drawdown)}
买入持有
${fmtPct(m.benchmark)}
`;
renderEquity(d.equity);
renderTrades(d.trades);
}
function renderEquity(equity) {
const el = $('#equityChart');
if (!window.echarts) { el.innerHTML = 'ECharts 加载失败
'; 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 = '该策略在回测期内无交易信号
'; return; }
$('#tradeBox').innerHTML = `
| 买入日期 | 买入价 | 卖出日期 | 卖出价 | 收益 | 持有(天) | 信号 |
${trades.map(t => `
| ${t.entry_date} | ${t.entry_price} |
${t.exit_date} | ${t.exit_price} |
${fmtPct(t.return)} |
${t.days} |
${escapeHtml(t.reason)} |
`).join('')}
`;
}
window.addEventListener('resize', () => chart && chart.resize());
loadStrategies();