Files
ggx/web/app.js
T
simon fb6608193b 功能:Web 前端与报告格式化(工作区中此前未提交的工作)
说明:本提交**不是本轮会话所做**,而是工作区里此前遗留的未提交改动。
为把历史分开,先单独提交它,再提交本轮会话的修改。

包含:
- Web 前端:web/index.html、web/app.js(统一 SPA,含回测/画像/Walk-forward 页面)
- 后端接口:web/server.py 路由、web/analysis.py(新增个股分析)
- 报告层:report/format.py(新增统一数字格式化 NumFmt)、
  report/{backtest,profile,sensitivity,universe,walkforward}_report.py 接入 NumFmt、
  report/renderer.py
- 股息率口径:factor/dividend_yield.py(毛刺消除 smooth_spikes)
- 筛选:universe/selector.py、universe/filters/dividend.py
- 绩效/敏感性:analysis/performance.py、analysis/sensitivity.py
- 部署:deploy/install-service.sh
- 测试:tests/test_format.py、tests/test_dividend_smoothing.py(新增)、
  tests/test_web.py、tests/test_universe.py

提交时全量测试 403 项通过。
2026-10-04 12:47:10 +08:00

1648 lines
82 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
高股息回测系统 · 统一前端
前后端分离:本文件只通过 /api/* 读写数据,不做任何指标计算。
路由使用 hash(#/...),因此 nginx 只需托管静态文件 + 反代 /api。
============================================================ */
'use strict';
const API = (() => {
// 允许把前端挂在与 API 不同的路径下:<meta name="api-base" content="/api">
const meta = document.querySelector('meta[name="api-base"]');
return (meta && meta.content) || 'api';
})();
const COLORS = ['#1E40AF','#D97706','#3B82F6','#059669','#DC2626',
'#7C3AED','#0891B2','#CA8A04','#DB2777','#65A30D'];
/* ---------------- 工具 ---------------- */
const esc = s => String(s ?? '').replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// 显示精度来自 config/report.yml: layout.decimals(启动时从 /api/config/display 取)。
// 曾经这里硬编码 2 位,改配置不会生效 —— 与报告层是同一个毛病。
const FMT = {ratio: 4, money: 2, price: 2, percent: 2};
const num = (v, d) => (v === null || v === undefined || Number.isNaN(v))
? '—' : Number(v).toLocaleString('zh-CN',
{minimumFractionDigits: d === undefined ? FMT.ratio : d,
maximumFractionDigits: d === undefined ? FMT.ratio : d});
// 百分比小数位 = ratio - 2(比率保留 ratio 位后乘 100,恰好少两位)
const pct = (v, d) => (v === null || v === undefined || Number.isNaN(v))
? '—' : (v * 100).toFixed(d === undefined ? FMT.percent : d) + '%';
const pp = (v, d) => (v === null || v === undefined || Number.isNaN(v))
? '—' : ((v > 0 ? '+' : '') + (v * 100).toFixed(d === undefined ? FMT.percent : d) + 'pp');
const yi = v => (v === null || v === undefined) ? '—'
: (Math.abs(v) >= 1e8 ? (v/1e8).toFixed(1) + ' 亿' : num(v, 0));
const sign = v => (v === null || v === undefined) ? '' : (v > 0 ? 'gain' : (v < 0 ? 'loss' : ''));
function toast(msg, isErr) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show' + (isErr ? ' err' : '');
clearTimeout(t._timer);
t._timer = setTimeout(() => { t.className = 'toast'; }, 2600);
}
async function api(path, opts) {
const res = await fetch(`${API}/${path}`, Object.assign({
headers: {'Content-Type': 'application/json'}
}, opts));
const text = await res.text();
let data;
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = {error: text.slice(0,200)}; }
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
const patch = (path, body) =>
api(path, {method: 'PATCH', body: JSON.stringify(body)});
/* ---------------- 图表管理 ---------------- */
const charts = [];
function chart(id, option) {
const el = document.getElementById(id);
if (!el || typeof echarts === 'undefined') return null;
const c = echarts.init(el);
c.setOption(Object.assign({color: COLORS}, option));
charts.push(c);
return c;
}
function disposeCharts() { while (charts.length) { try { charts.pop().dispose(); } catch (e) {} } }
/** 只销毁一张图(切换叠加指数时重画同一张图,不动页面上其它图)。 */
function disposeChart(id) {
const el = document.getElementById(id);
if (!el || typeof echarts === 'undefined') return;
const inst = echarts.getInstanceByDom(el);
if (!inst) return;
inst.dispose();
const i = charts.indexOf(inst);
if (i >= 0) charts.splice(i, 1);
}
window.addEventListener('resize', () => charts.forEach(c => c.resize()));
/* ---------------- 模态框 ---------------- */
function modal(title, fields, onOk) {
const bg = document.createElement('div');
bg.className = 'modal-bg';
bg.innerHTML = `<div class="modal">
<h3>${esc(title)}</h3>
${fields.map(f => `<div class="field">
<label>${esc(f.label)}</label>
${f.type === 'textarea'
? `<textarea id="f_${f.key}" rows="3">${esc(f.value || '')}</textarea>`
: `<input type="text" id="f_${f.key}" value="${esc(f.value || '')}">`}
</div>`).join('')}
<div class="modal-actions">
<button data-act="cancel">取消</button>
<button class="primary" data-act="ok">确定</button>
</div></div>`;
document.body.appendChild(bg);
const close = () => bg.remove();
bg.addEventListener('click', e => {
if (e.target === bg || e.target.dataset.act === 'cancel') close();
if (e.target.dataset.act === 'ok') {
const out = {};
fields.forEach(f => { out[f.key] = document.getElementById('f_' + f.key).value.trim(); });
close();
onOk(out);
}
});
const first = bg.querySelector('input,textarea');
if (first) { first.focus(); first.select(); }
}
/* ---------------- 视图:概览 ---------------- */
async function viewHome() {
const [s, us, bs] = await Promise.all([
api('summary'), api('universes'), api('backtests')
]);
document.getElementById('db-info').textContent = s.db || '';
const kpi = (label, value, note, cls) => `<div class="kpi">
<div class="label">${esc(label)}</div>
<div class="value ${cls || ''}">${value}</div>
<div class="note">${esc(note)}</div></div>`;
return `
<h1 class="page">概览</h1>
<div class="page-sub">数据来自 ${esc(s.db)};最近同步 ${esc(s.last_sync || '—')}</div>
<div class="kpi-grid">
${kpi('股票池筛选记录', s.universes_active, `共 ${s.universes} 条(含归档)`)}
${kpi('回测记录', s.backtests_active, `共 ${s.backtests} 条(含归档)`)}
${kpi('最新筛选时点', s.latest_asof || '—', 'Point-in-Time')}
${kpi('Walk-forward', s.walkforwards, '样本外验证运行数')}
${kpi('画像运行', s.profiles, '个股特性画像批次')}
${kpi('参数敏感性', s.sensitivities, '扫描运行数')}
</div>
<div class="card">
<h2>最近的股票池筛选 <a class="small" href="#/universes">查看全部 →</a></h2>
${us.items.length ? `<div class="rec-list">${us.items.slice(0,4).map(recUniverse).join('')}</div>`
: '<div class="empty">还没有筛选记录。<br>运行 <code class="mono">python -m hdiv universe --asof 2024-06-28</code> 创建一条。</div>'}
</div>
<div class="card">
<h2>最近的回测 <a class="small" href="#/backtests">查看全部 →</a></h2>
${bs.items.length ? `<div class="rec-list">${bs.items.slice(0,4).map(recBacktest).join('')}</div>`
: '<div class="empty">还没有回测记录。<br>运行 <code class="mono">python -m hdiv backtest</code> 创建一条。</div>'}
</div>`;
}
/* ---------------- 视图:股票池列表 ---------------- */
function recUniverse(u) {
return `<div class="rec ${u.state !== 'active' ? 'dim' : ''}">
<div class="rec-main">
<div class="rec-title">
<a href="#/universes/${esc(u.run_id)}">${esc(u.title)}</a>
${u.state === 'archived' ? '<span class="badge warn">已归档</span>' : ''}
${u.state === 'deleted' ? '<span class="badge fail">已删除</span>' : ''}
${u.linked_backtests ? `<span class="badge ok">关联 ${u.linked_backtests} 次回测</span>` : ''}
</div>
<div class="rec-meta">
<span>时点 <b class="mono">${esc(u.asof_date)}</b></span>
<span>候选 <b class="mono">${num(u.candidate_count,0)}</b></span>
<span>入选 <b class="mono">${num(u.member_count,0)}</b></span>
<span class="mono">${esc((u.created_at||'').slice(0,16))}</span>
</div>
${u.notes ? `<div class="rec-meta"><span>备注:${esc(u.notes)}</span></div>` : ''}
</div>
<div class="rec-actions">
<button class="primary" data-act="open" data-id="${esc(u.run_id)}">打开股票清单</button>
<button data-act="rename" data-id="${esc(u.run_id)}">命名</button>
<button data-act="archive" data-id="${esc(u.run_id)}" data-to="${u.state === 'archived' ? '0' : '1'}">
${u.state === 'archived' ? '取消归档' : '归档'}</button>
${u.state === 'deleted'
? `<button data-act="delete" data-id="${esc(u.run_id)}" data-to="0">恢复</button>`
: `<button class="danger" data-act="delete" data-id="${esc(u.run_id)}" data-to="1">删除</button>`}
</div>
</div>`;
}
async function viewUniverses(params) {
const incArch = params.get('archived') === '1';
const incDel = params.get('deleted') === '1';
const q = params.get('q') || '';
const d = await api(`universes?include_archived=${incArch?1:0}&include_deleted=${incDel?1:0}` +
(q ? `&q=${encodeURIComponent(q)}` : ''));
return `
<h1 class="page">股票池筛选记录</h1>
<div class="page-sub">每次 <code class="mono">hdiv universe</code> 执行产生一条记录。可命名、归档或删除。</div>
<div class="callout">
<b>「删除」是软删除</b>:记录被隐藏,但数据仍完整保留在数据库中,可随时恢复。
本项目对研究数据坚持只增不删。
</div>
<div class="toolbar">
<div class="grow"><input type="search" id="q" placeholder="搜索名称 / 备注 / run_id / 时点…" value="${esc(q)}"></div>
<label class="check"><input type="checkbox" id="incArch" ${incArch?'checked':''}> 显示已归档</label>
<label class="check"><input type="checkbox" id="incDel" ${incDel?'checked':''}> 显示已删除</label>
<button id="doSearch">搜索</button>
</div>
${d.items.length ? `<div class="rec-list">${d.items.map(recUniverse).join('')}</div>`
: '<div class="empty">没有匹配的筛选记录。</div>'}`;
}
/* ---------------- 视图:股票池详情 ---------------- */
async function viewUniverseDetail(runId, params) {
const passed = params.get('passed');
const page = params.get('page') || 1;
const q = params.get('q') || '';
const showAll = passed === 'all';
const query = `passed=${showAll ? 'all' : (passed === '0' ? '0' : '1')}` +
`&page=${page}&size=50` + (q ? `&q=${encodeURIComponent(q)}` : '');
const [u, m, bts] = await Promise.all([
api(`universes/${runId}`), api(`universes/${runId}/members?${query}`),
api(`universes/${runId}/backtests`)
]);
const rows = m.items.map(x => `<tr>
<td><a href="#/stocks/${esc(x.symbol)}" class="mono">${esc(x.symbol)}</a></td>
<td class="l">${esc(x.name || '')}</td>
<td class="l small muted">${esc(x.industry || '')}</td>
<td class="num ${x.passed ? '' : 'muted'}">${pct(x.dividend_yield)}</td>
<td class="num">${x.dividend_continuity_years ?? '—'}</td>
<td class="num">${pct(x.payout_ratio)}</td>
<td class="num">${num(x.fcf_dividend_cover)}</td>
<td class="num">${pct(x.roe)}</td>
<td class="num">${pct(x.debt_ratio)}</td>
<td class="num">${yi(x.total_mv)}</td>
<td class="l small">${x.passed ? '<span class="badge ok">入选</span>'
: `<span class="badge fail">${esc(x.fail_stage || '')}</span> <span class="muted">${esc(x.fail_reason || '')}</span>`}</td>
</tr>`).join('');
const stats = u.stats || {};
const stages = ['market','risk','dividend','quality'].map(k =>
`<span class="rec-metric">${k} 淘汰 <b>${stats[k] ?? '—'}</b></span>`).join('');
return `
<div class="crumb"><a href="#/universes">股票池筛选</a><span>/</span>${esc(u.asof_date)}</div>
<h1 class="page">${esc(u.title)}</h1>
<div class="page-sub">
时点 <b class="mono">${esc(u.asof_date)}</b> ·
候选 ${num(u.candidate_count,0)} · 入选 <b class="mono">${num(u.member_count,0)}</b> ·
<span class="mono">${esc(u.run_id)}</span>
</div>
<div class="card">
<h2>记录操作</h2>
<div class="toolbar" style="margin:0">
<button class="primary" data-act="rename" data-id="${esc(runId)}" data-scope="u">命名 / 备注</button>
<button data-act="archive" data-id="${esc(runId)}" data-scope="u" data-to="${u.archived_at ? '0':'1'}">
${u.archived_at ? '取消归档' : '归档'}</button>
${u.deleted_at
? `<button data-act="delete" data-id="${esc(runId)}" data-scope="u" data-to="0">恢复</button>`
: `<button class="danger" data-act="delete" data-id="${esc(runId)}" data-scope="u" data-to="1">删除</button>`}
${u.state !== 'active' ? `<span class="badge warn">${u.state === 'archived' ? '已归档':'已删除'}</span>` : ''}
</div>
${u.notes ? `<div class="rec-meta" style="margin-top:10px">备注:${esc(u.notes)}</div>` : ''}
</div>
<div class="grid2">
<div class="card">
<h2>筛选漏斗</h2>
<div id="u_funnel" class="chart short"></div>
<div class="rec-metrics" style="margin-top:10px">
<span class="rec-metric">候选<b>${num(u.candidate_count,0)}</b></span>
<span class="rec-metric">入选<b>${num(u.member_count,0)}</b></span>
<span class="rec-metric">通过率<b>${u.candidate_count ? ((u.member_count/u.candidate_count)*100).toFixed(2)+'%' : '—'}</b></span>
</div>
</div>
<div class="card">
<h2>行业分布 <span class="badge info">${(u.industry_distribution||[]).length} 个行业</span></h2>
<div id="u_industry" class="chart short"></div>
</div>
</div>
<div class="card">
<h2>关联的回测 <span class="badge info">${bts.items.length}</span></h2>
${bts.items.length
? `<div class="rec-list">${bts.items.map(b => recBacktest(b, true)).join('')}</div>`
: `<div class="empty">这条筛选记录还没有关联回测。<br>
运行 <code class="mono">python -m hdiv backtest --universe-run ${esc(runId)}</code>,
或在下方「回测」页把已有回测关联过来。</div>`}
</div>
<div class="card">
<h2>股票清单 <span class="badge info">${num(m.total,0)}</span></h2>
<div class="toolbar">
<div class="grow"><input type="search" id="mq" placeholder="搜索代码 / 名称 / 行业…" value="${esc(q)}"></div>
<select id="mfilter" style="width:auto">
<option value="1" ${!showAll && passed!=='0' ? 'selected':''}>仅入选</option>
<option value="0" ${passed==='0' ? 'selected':''}>仅淘汰</option>
<option value="all" ${showAll ? 'selected':''}>全部候选</option>
</select>
<button id="mgo">筛选</button>
</div>
<div class="tw"><table class="data">
<thead><tr>
<th class="l">代码</th><th class="l">名称</th><th class="l">行业</th>
<th>股息率</th><th>连续分红</th><th>支付率</th><th>FCF覆盖</th>
<th>ROE</th><th>负债率</th><th>总市值</th><th class="l">结果 / 未通过原因</th>
</tr></thead>
<tbody>${rows || '<tr><td colspan="11" class="empty">无数据</td></tr>'}</tbody>
</table></div>
<div class="pager">
<span>第 ${m.page} / ${m.pages || 1} 页,共 ${num(m.total,0)} 条</span>
<button data-act="page" data-p="${m.page-1}" ${m.page<=1?'disabled':''}>上一页</button>
<button data-act="page" data-p="${m.page+1}" ${m.page>=m.pages?'disabled':''}>下一页</button>
</div>
<div class="rec-metrics" style="margin-top:14px">${stages}</div>
</div>
<div class="card">
<h2>生效的筛选配置</h2>
<details><summary class="small muted" style="cursor:pointer">展开完整 JSON</summary>
<pre class="mono small" style="white-space:pre-wrap;max-height:420px;overflow:auto">${esc(JSON.stringify(u.config, null, 2))}</pre>
</details>
</div>`;
}
/* 股票池详情的图表:数据由后端算好,前端只渲染 */
function mountUniverseDetail(u) {
const f = u.funnel;
if (f && f.labels && f.labels.length) {
// 横向条形:候选(数千只)会压倒其余各段,这是事实本身
// (92% 在市值门槛就被淘汰)。因此在条末直接标数值,保证小段也可读。
const rows = f.labels.map((l, i) => ({
name: l, value: f.values[i],
eliminated: i === 0 ? null : f.eliminated[i - 1]
})).reverse();
chart('u_funnel', {
tooltip: {
trigger: 'item',
formatter: p => {
const d = rows[p.dataIndex];
return `${d.name}<br/>存活 <b>${d.value.toLocaleString()}</b>` +
(d.eliminated != null ? `<br/>本环节淘汰 ${d.eliminated.toLocaleString()}` : '');
}
},
grid: {left: 74, right: 76, top: 12, bottom: 24},
xAxis: {type: 'value', axisLabel: {color: '#64748B'},
splitLine: {lineStyle: {color: '#E9EEF6'}}},
yAxis: {type: 'category', data: rows.map(r => r.name),
axisLabel: {color: '#64748B'}, axisTick: {show: false}},
series: [{
type: 'bar', barMaxWidth: 22,
data: rows.map(r => r.value),
itemStyle: {color: p => COLORS[Math.min(p.dataIndex, COLORS.length - 1)]},
label: {show: true, position: 'right', color: '#0F172A', fontSize: 11,
formatter: p => p.value.toLocaleString()}
}]
});
}
const dist = (u.industry_distribution || []).slice(0, 12);
if (dist.length) {
chart('u_industry', {
tooltip: {trigger: 'item', formatter: p => `${p.name}<br/>${p.value} 只(${p.percent}%)`},
grid: {left: 92, right: 60, top: 10, bottom: 24},
xAxis: {type: 'value', axisLabel: {color: '#64748B'},
splitLine: {lineStyle: {color: '#E9EEF6'}}},
yAxis: {type: 'category', data: dist.map(d => d.industry).reverse(),
axisLabel: {color: '#64748B'}, axisTick: {show: false}},
series: [{
type: 'bar', barMaxWidth: 18,
data: dist.map(d => d.count).reverse(),
itemStyle: {color: COLORS[0]},
label: {show: true, position: 'right', color: '#0F172A', fontSize: 11}
}]
});
}
}
/* ---------------- 视图:个股画像 ---------------- */
async function viewStock(symbol) {
let d;
try { d = await api(`stocks/${encodeURIComponent(symbol)}`); }
catch (e) {
return `<div class="crumb"><a href="#/universes">返回</a></div>
<div class="callout fail"><b>无法打开画像:</b>${esc(e.message)}</div>`;
}
const info = d.info || {};
const ys = d.yield_summary || {};
const cur = ys.current_value, cp = ys.current_percentile;
const statRows = (d.stats || []).map(s => {
const u = UNIT[s.metric_code] || 'ratio';
const f = v => fmtUnit(v, u);
return `<tr>
<td class="l">${esc(LABEL[s.metric_code] || s.metric_code)}</td>
<td class="num">${s.window_years == 0 ? '全历史' : s.window_years + '年'}</td>
<td class="num">${num(s.n_obs,0)}</td>
<td class="num">${f(s.min_value)}</td><td class="num">${f(s.p10)}</td>
<td class="num">${f(s.p25)}</td><td class="num">${f(s.p50)}</td>
<td class="num">${f(s.p75)}</td><td class="num">${f(s.p90)}</td>
<td class="num">${f(s.max_value)}</td>
<td class="num"><b>${f(s.current_value)}</b></td>
<td class="num ${s.current_percentile >= 75 ? 'gain' : ''}">${num(s.current_percentile,1)}%</td>
<td><span class="badge ${s.status === 'OK' ? 'ok':'warn'}">${esc(s.status)}</span></td>
</tr>`;
}).join('');
const scoreRows = (d.scores || []).map(s => `<tr>
<td class="l">${esc(SCORE_LABEL[s.score_code] || s.score_code)}</td>
<td class="num">${num(s.score,1)}</td></tr>`).join('');
return `
<div class="crumb"><a href="#/">概览</a><span>/</span>个股画像</div>
<h1 class="page">${esc(info.name || symbol)} <span class="mono muted" style="font-size:15px">${esc(symbol)}</span></h1>
<div class="page-sub">${esc(info.industry || '—')} · ${esc(info.market || '')} ·
上市 ${esc(info.list_date || '—')} · 画像时点 ${esc((d.profile_run && d.profile_run.asof_date) || '—')}</div>
<div class="kpi-grid">
<div class="kpi"><div class="label">当前股息率</div>
<div class="value ${cp >= 75 ? 'gain':''}">${pct(cur)}</div>
<div class="note">TTM 每股分红 / 不复权收盘价</div></div>
<div class="kpi"><div class="label">历史分位</div>
<div class="value ${cp >= 75 ? 'gain':''}">${num(cp,1)}%</div>
<div class="note">当前值在自身历史分布中的位置</div></div>
<div class="kpi"><div class="label">P75 / P25</div>
<div class="value" style="font-size:17px">${pct(ys.p75)} / ${pct(ys.p25)}</div>
<div class="note">买入 / 卖出阈值参考</div></div>
<div class="kpi"><div class="label">样本观测</div>
<div class="value">${num(ys.n_obs,0)}</div>
<div class="note">日频观测数</div></div>
</div>
<div class="card">
<h2>K线 · 股息率 · PE · 回撤</h2>
<div id="c_main" class="chart tall"></div>
</div>
<div class="grid2">
<div class="card"><h2>股息率历史分布</h2><div id="c_hist" class="chart short"></div></div>
<div class="card"><h2>安全边际分项得分</h2>
${scoreRows ? `<div id="c_score" class="chart short"></div>
<div class="tw"><table class="data"><thead><tr><th class="l">分项</th><th>得分</th></tr></thead>
<tbody>${scoreRows}</tbody></table></div>`
: '<div class="callout warn">分项数据不足,未计算得分(不使用残缺数据造分)。</div>'}
</div>
</div>
<div class="card">
<h2>历史分布统计</h2>
<div class="tw"><table class="data">
<thead><tr><th class="l">指标</th><th>窗口</th><th>样本</th>
<th>Min</th><th>P10</th><th>P25</th><th>P50</th><th>P75</th><th>P90</th><th>Max</th>
<th>当前</th><th>分位</th><th>状态</th></tr></thead>
<tbody>${statRows || '<tr><td colspan="13" class="empty">无数据</td></tr>'}</tbody>
</table></div>
</div>`;
}
function mountStock(d) {
const ch = d.chart || {};
if (!ch.dates || !ch.dates.length) return;
const grid = (t,h) => ({left:62,right:62,top:t,height:h});
chart('c_main', {
tooltip:{trigger:'axis',axisPointer:{type:'cross'}},
axisPointer:{link:[{xAxisIndex:'all'}]},
legend:{top:0,textStyle:{color:'#64748B'}},
grid:[grid(34,190),grid(262,120),grid(420,110)],
xAxis:[
{type:'category',data:ch.dates,gridIndex:0,axisLabel:{show:false},axisTick:{show:false}},
{type:'category',data:ch.dates,gridIndex:1,axisLabel:{show:false},axisTick:{show:false}},
{type:'category',data:ch.dates,gridIndex:2,
axisLabel:{color:'#64748B',formatter:v=>String(v).slice(0,7)}}],
yAxis:[
{scale:true,gridIndex:0,name:'价格',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}},
{scale:true,gridIndex:1,name:'股息率%',axisLabel:{color:'#64748B',formatter:v=>(v*100).toFixed(1)},
splitLine:{lineStyle:{color:'#E9EEF6'}}},
{scale:true,gridIndex:2,name:'PE',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}}],
dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:8}],
series:[
{name:'收盘价',type:'line',xAxisIndex:0,yAxisIndex:0,data:ch.close,showSymbol:false,
lineStyle:{width:1.4,color:COLORS[0]}},
{name:'股息率',type:'line',xAxisIndex:1,yAxisIndex:1,data:ch.dv_yield,showSymbol:false,
lineStyle:{width:1.6,color:COLORS[1]},areaStyle:{opacity:.12},
markLine:{silent:true,symbol:'none',data:[
...(d.yield_summary && d.yield_summary.p75 != null ? [{yAxis:d.yield_summary.p75,
lineStyle:{color:COLORS[4],type:'dashed'},
label:{formatter:'买入 P75',color:COLORS[4],position:'insideEndTop'}}] : []),
...(d.yield_summary && d.yield_summary.p25 != null ? [{yAxis:d.yield_summary.p25,
lineStyle:{color:COLORS[3],type:'dashed'},
label:{formatter:'卖出 P25',color:COLORS[3],position:'insideEndBottom'}}] : [])]}},
{name:'PE(TTM)',type:'line',xAxisIndex:2,yAxisIndex:2,data:ch.pe_ttm,showSymbol:false,
lineStyle:{width:1.2,color:COLORS[2]}}]
});
// 直方图(用序列值前端分箱,属于纯呈现,不是业务计算)
const vals = (ch.dv_yield || []).filter(v => v != null && v > 0);
if (vals.length) {
const lo = Math.min(...vals), hi = Math.max(...vals), N = 12;
const w = (hi - lo) / N || 1;
const counts = new Array(N).fill(0);
vals.forEach(v => counts[Math.min(N-1, Math.floor((v - lo) / w))]++);
const labels = counts.map((_, i) => ((lo + w*(i+0.5))*100).toFixed(2) + '%');
chart('c_hist', {
tooltip:{trigger:'axis'},
grid:{left:56,right:20,top:20,bottom:44},
xAxis:{type:'category',data:labels,axisLabel:{color:'#64748B',rotate:40,fontSize:10}},
yAxis:{type:'value',name:'天数',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}},
series:[{type:'bar',data:counts,itemStyle:{color:COLORS[0]},barMaxWidth:24}]
});
}
if (d.scores && d.scores.length) {
const ind = d.scores.filter(s => s.score != null && s.score_code !== 'composite')
.map(s => ({name: SCORE_LABEL[s.score_code] || s.score_code, max: 100}));
const val = d.scores.filter(s => s.score != null && s.score_code !== 'composite')
.map(s => Number(s.score.toFixed(1)));
if (ind.length >= 3) {
chart('c_score', {
tooltip:{}, radar:{indicator:ind,axisName:{color:'#64748B',fontSize:11},
splitLine:{lineStyle:{color:'#E9EEF6'}},splitArea:{areaStyle:{color:['#fff','#F8FAFC']}}},
series:[{type:'radar',data:[{value:val,name:'安全边际',areaStyle:{opacity:.2},
lineStyle:{color:COLORS[0]},itemStyle:{color:COLORS[0]}}]}]
});
}
}
}
const LABEL = {dv_yield:'股息率',pe_ttm:'PE(TTM)',pb:'PB',ps_ttm:'PS(TTM)',close:'收盘价',
drawdown:'回撤',ttm_dps:'TTM 每股分红',dps:'每股分红',roe:'ROE',roic:'ROIC',
gross_margin:'毛利率',net_margin:'净利率',debt_ratio:'资产负债率',
ocf_to_profit:'经营现金流/净利润',payout_ratio:'分红支付率',
fcf_dividend_cover:'FCF 对分红覆盖',dividend_continuity_years:'连续分红年数',
dividend_years_in_window:'窗口内分红年数',dps_cagr_5y:'DPS 5年复合增速',
dps_volatility:'DPS 波动率',free_cashflow:'自由现金流',total_cash_dividend:'总现金分红',
vol_250d:'年化波动率',max_drawdown_3y:'3年最大回撤',beta_300:'Beta(沪深300)',
avg_amount_20d:'20日均成交额',ret_1y:'近1年收益',ret_3y:'近3年收益',
ret_5y:'近5年收益',cagr_5y:'5年复合收益',roe_avg:'ROE(5年均)',roic_avg:'ROIC(5年均)',
gross_margin_avg:'毛利率(5年均)',net_margin_avg:'净利率(5年均)',
ocf_to_profit_avg:'经营现金流/净利润(5年均)',dv_vol_daily:'股息率日频波动',
dv_vol_monthly:'股息率月频波动',dv_vol_quarterly:'股息率季频波动',
dv_vol_annual:'股息率年频波动',n_income_attr_p:'归母净利润',total_assets:'总资产'};
const UNIT = {dv_yield:'pct',drawdown:'pct',roe:'pct',roic:'pct',gross_margin:'pct',
net_margin:'pct',debt_ratio:'pct',payout_ratio:'pct',dps_cagr_5y:'pct',
vol_250d:'pct',max_drawdown_3y:'pct',ret_1y:'pct',ret_3y:'pct',ret_5y:'pct',
cagr_5y:'pct',roe_avg:'pct',roic_avg:'pct',gross_margin_avg:'pct',
net_margin_avg:'pct',dv_vol_daily:'ratio',dv_vol_monthly:'ratio',
dv_vol_quarterly:'ratio',dv_vol_annual:'ratio',dividend_continuity_years:'int',
dividend_years_in_window:'int',avg_amount_20d:'money',free_cashflow:'money',
total_cash_dividend:'money',n_income_attr_p:'money',total_assets:'money',
close:'price',dps:'price',ttm_dps:'price'};
const SCORE_LABEL = {dividend_yield:'股息率安全边际',valuation:'估值安全边际',
financial_quality:'财务质量',balance_sheet:'资产负债表',
dividend_quality:'分红质量',composite:'综合得分'};
function fmtUnit(v, u) {
if (v === null || v === undefined) return '—';
if (u === 'pct') return pct(v);
if (u === 'money') return yi(v);
if (u === 'int') return num(v, 0);
return num(v, u === 'price' ? 2 : 4);
}
/* ---------------- 视图:回测列表 ---------------- */
function recBacktest(b, compact) {
const m = b.metrics || {};
return `<div class="rec ${b.state !== 'active' ? 'dim' : ''}">
<div class="rec-main">
<div class="rec-title">
<a href="#/backtests/${esc(b.run_id)}">${esc(b.title)}</a>
${b.state === 'archived' ? '<span class="badge warn">已归档</span>' : ''}
${b.state === 'deleted' ? '<span class="badge fail">已删除</span>' : ''}
${b.mode && b.mode !== 'single' ? `<span class="badge info">${esc(b.mode)}</span>` : ''}
</div>
${compact ? '' : `<div class="rec-meta">
<span>${esc(b.start_date)} ~ ${esc(b.end_date)}</span>
<span class="mono">${esc((b.created_at||'').slice(0,16))}</span>
${b.universe_run_id ? `<a href="#/universes/${esc(b.universe_run_id)}">← 来源股票池</a>` : ''}
</div>`}
${(b.strategy && b.strategy.conditions)
? `<div class="rec-meta" style="margin-top:8px">${b.strategy.conditions.map(c =>
`<span><b>${esc(c.group)}</b>:${esc(c.text)}</span>`).join('')}</div>` : ''}
${m.total_return != null ? `<div class="rec-metrics">
<span class="rec-metric">总收益<b class="${sign(m.total_return)}">${pct(m.total_return)}</b></span>
<span class="rec-metric">CAGR<b class="${sign(m.cagr)}">${pct(m.cagr)}</b></span>
<span class="rec-metric">最大回撤<b class="loss">${pct(m.max_drawdown)}</b></span>
<span class="rec-metric">Sharpe<b>${num(m.sharpe)}</b></span>
<span class="rec-metric">成交<b>${num(m.trade_count,0)}</b></span>
</div>` : ''}
</div>
<div class="rec-actions">
<button class="primary" data-act="open" data-id="${esc(b.run_id)}" data-scope="b">打开详细结果</button>
<button data-act="rename" data-id="${esc(b.run_id)}" data-scope="b">命名</button>
<button data-act="archive" data-id="${esc(b.run_id)}" data-scope="b" data-to="${b.archived_at ? '0':'1'}">
${b.archived_at ? '取消归档' : '归档'}</button>
${b.deleted_at
? `<button data-act="delete" data-id="${esc(b.run_id)}" data-scope="b" data-to="0">恢复</button>`
: `<button class="danger" data-act="delete" data-id="${esc(b.run_id)}" data-scope="b" data-to="1">删除</button>`}
</div>
</div>`;
}
async function viewBacktests(params) {
const incArch = params.get('archived') === '1';
const d = await api(`backtests?include_archived=${incArch?1:0}`);
return `
<h1 class="page">回测记录</h1>
<div class="page-sub">每条记录显示回测条件与总体结果。点击「打开详细结果」查看净值、指标与逐笔成交。</div>
<div class="toolbar">
<label class="check"><input type="checkbox" id="incArch" ${incArch?'checked':''}> 显示已归档</label>
</div>
${d.items.length ? `<div class="rec-list">${d.items.map(b => recBacktest(b)).join('')}</div>`
: `<div class="empty">还没有回测记录。<br>运行 <code class="mono">python -m hdiv backtest</code> 创建一条。</div>`}`;
}
/* ---------------- 视图:回测详情 ---------------- */
async function viewBacktestDetail(runId) {
const b = await api(`backtests/${runId}`);
const m = {};
(await api(`backtests/${runId}/metrics`)).items
.filter(x => x.scope === 'all' && (!x.benchmark_code || x.benchmark_code === ''))
.forEach(x => { m[x.metric_code] = x.metric_value; });
const rc = b.reconciliation || {};
return `
<div class="crumb"><a href="#/backtests">回测记录</a><span>/</span>${esc(b.strategy_id)}</div>
<h1 class="page">${esc(b.title)}</h1>
<div class="page-sub">${esc(b.start_date)} ~ ${esc(b.end_date)} · 模式 ${esc(b.mode)} ·
<span class="mono">${esc(b.run_id)}</span>
${b.universe_run_id ? ` · 来源股票池 <a href="#/universes/${esc(b.universe_run_id)}" class="mono">${esc(b.universe_run_id)}</a>` : ''}
</div>
<div class="kpi-grid">
<div class="kpi"><div class="label">总收益</div>
<div class="value ${sign(m.total_return)}">${pct(m.total_return)}</div>
<div class="note">期初 ${yi(b.initial_capital)} → 期末 ${yi(b.final_capital)}</div></div>
<div class="kpi"><div class="label">年化 CAGR</div>
<div class="value ${sign(m.cagr)}">${pct(m.cagr)}</div>
<div class="note">区间 ${num(m.years,2)} 年</div></div>
<div class="kpi"><div class="label">最大回撤</div>
<div class="value loss">${pct(m.max_drawdown)}</div>
<div class="note">相对历史高点</div></div>
<div class="kpi"><div class="label">Sharpe</div>
<div class="value ${sign(m.sharpe)}">${num(m.sharpe)}</div>
<div class="note">Sortino ${num(m.sortino)} · Calmar ${num(m.calmar)}</div></div>
<div class="kpi"><div class="label">成交笔数</div>
<div class="value">${num(m.trade_count,0)}</div>
<div class="note">胜率 ${pct(m.win_rate)} · 平均持仓 ${num(m.avg_holding_days,0)} 天</div></div>
<div class="kpi"><div class="label">累计现金分红</div>
<div class="value">${yi(b.total_dividend_net)}</div>
<div class="note">已扣红利税 ${yi(b.total_dividend_tax)}</div></div>
</div>
<div class="callout ${rc.balanced ? 'ok' : 'fail'}">
<b>资金对账${rc.balanced ? '通过' : '未通过'}</b> ——
恒等式「期初 = 期末现金 + 买入 − 卖出 + 费用 − 分红」残差
<span class="mono">${num(rc.residual, 4)}</span> 元。
${rc.balanced ? '成本与分红入账完整无遗漏。' : '<b>此时不应采信上方绩效指标。</b>'}
</div>
<div class="card">
<h2>净值曲线与基准</h2>
<div class="toolbar">
<span class="small muted">叠加指数(右轴)</span>
<select id="eq-index" style="width:auto"></select>
<span class="small muted" id="eq-index-note"></span>
</div>
<div id="c_eq" class="chart"></div>
<div class="callout" style="margin-top:12px">
<b>右轴</b>叠加的是所选指数的<b>点位</b>(不归一化),左轴是净值(起点 = 1);
虚线「基准净值」是本次回测<b>自带</b>的基准,已归一到 1,可直接与策略净值比高低。
指数默认沪深300,可在上方下拉切换或取消叠加。
</div>
</div>
<div class="card" id="portfolio-card">
<h2>持仓明细 <span class="badge info" id="pf-badge">…</span></h2>
<div class="toolbar">
<span class="small muted">日期</span>
<input type="date" id="pf-date" style="width:auto">
<button data-act="pf-shift" data-d="-1">← 上一交易日</button>
<button data-act="pf-shift" data-d="1">下一交易日 →</button>
<button data-act="pf-shift" data-d="0">最新</button>
<span class="small muted" id="pf-range"></span>
</div>
<div id="pf-body"><div class="loading">加载中…</div></div>
</div>
<div class="card">
<h2>回测条件</h2>
<div class="tw"><table class="data">
<thead><tr><th class="l">类别</th><th class="l">条件</th></tr></thead>
<tbody>${(b.strategy.conditions||[]).map(c =>
`<tr><td class="l">${esc(c.group)}</td><td class="l">${esc(c.text)}</td></tr>`).join('')}
</tbody></table></div>
${b.strategy.description ? `<div class="callout" style="margin-top:12px">${esc(b.strategy.description)}</div>` : ''}
</div>
<div class="card">
<h2>逐笔成交与理由 <span class="badge info" id="trade-count">…</span></h2>
<div class="callout">
每笔交易都记录了<b>触发理由</b>(当时的股息率、历史分位、Rule 与参照样本数),
可回答「为什么买」。点击行首展开完整 reason。
</div>
<div id="trades"><div class="loading">加载中…</div></div>
</div>
<div class="card" id="skips-card" style="display:none">
<h2>未成交信号与原因</h2>
<div class="callout warn">这些信号「想交易但没成交」,用于判断约束是否实质影响绩效。</div>
<div id="skips"></div>
</div>
<div class="card">
<h2>可复现性</h2>
<div class="tw"><table class="data">
<tbody>
<tr><td class="l">策略</td><td class="l mono">${esc(b.strategy_id)} v${esc(b.strategy_version)}</td></tr>
<tr><td class="l">配置指纹 config_hash</td><td class="l mono">${esc(b.config_hash||'—')}</td></tr>
<tr><td class="l">数据版本 data_version</td><td class="l mono">${esc(b.data_version||'—')}</td></tr>
<tr><td class="l">代码版本 code_version</td><td class="l mono">${esc(b.code_version||'—')}</td></tr>
<tr><td class="l">随机种子 seed</td><td class="l mono">${esc(b.seed ?? '—')}</td></tr>
</tbody></table></div>
${(b.unimplemented && b.unimplemented.length)
? `<div class="callout warn" style="margin-top:12px"><b>未建模部分(如实声明):</b>
<ul style="margin:4px 0 0 18px">${b.unimplemented.map(x=>`<li>${esc(x)}</li>`).join('')}</ul></div>`
: ''}
</div>`;
}
const eqState = {runId: null, index: '', indices: []};
/** 净值曲线 + 可选的指数叠加(指数画在右轴,单位是点位,不参与净值刻度)。 */
async function mountEquityChart(runId, indexCode) {
const host = document.getElementById('c_eq');
if (!host) return;
disposeChart('c_eq');
const eq = await api(`backtests/${runId}/equity`
+ (indexCode ? `?index=${encodeURIComponent(indexCode)}` : ''));
const note = document.getElementById('eq-index-note');
if (!eq.dates || !eq.dates.length) {
host.innerHTML = '<div class="empty">该回测没有净值曲线</div>';
if (note) note.textContent = '';
return;
}
host.innerHTML = '';
disposeChart('c_eq'); // 连续切换下拉时,上一次请求可能已经 init 过
const ix = eq.index;
const isNav = n => n === '策略净值' || n === '基准净值';
// itemStyle 必须跟着写:图例与提示框的圆点取 itemStyle(缺省按调色板序号取色),
// 只写 lineStyle 会让「回撤」的圆点变成浅蓝、指数圆点变成绿色,与线色对不上。
const series = [
{name:'策略净值',type:'line',data:eq.nav,showSymbol:false,
lineStyle:{width:2,color:COLORS[0]},itemStyle:{color:COLORS[0]}},
{name:'基准净值',type:'line',data:eq.bench,showSymbol:false,
lineStyle:{width:1.4,color:COLORS[1],type:'dashed'},itemStyle:{color:COLORS[1]}},
{name:'回撤',type:'line',data:eq.drawdown,showSymbol:false,
lineStyle:{width:1,color:COLORS[4]},itemStyle:{color:COLORS[4]},
areaStyle:{opacity:.1}},
];
if (ix) {
series.push({
name:`${ix.name}(右轴)`, type:'line', yAxisIndex:1, data:ix.close,
showSymbol:false, connectNulls:true, // 指数偶有缺日,不因此断线
// 用紫色:与策略净值的深蓝、基准的橙虚线、回撤的红都拉开
lineStyle:{width:1.6,color:COLORS[5]}, itemStyle:{color:COLORS[5]},
});
}
// 净值 / 回撤 / 指数点位三种量纲,不能用同一个小数位
const rowFmt = p => {
const v = p.value;
const text = v == null ? '—'
: p.seriesName === '回撤' ? pct(v, 2)
: isNav(p.seriesName) ? num(v, 4)
: num(v, 2) + ' 点';
return `<div style="display:flex;gap:18px;justify-content:space-between;line-height:1.7">
<span>${p.marker}${esc(p.seriesName)}</span>
<b style="font-family:monospace">${text}</b></div>`;
};
chart('c_eq', {
tooltip:{trigger:'axis',axisPointer:{type:'cross'},
formatter: ps => `<div style="font-weight:600;margin-bottom:4px">`
+ `${esc(ps.length ? ps[0].axisValue : '')}</div>${ps.map(rowFmt).join('')}`},
legend:{top:0,textStyle:{color:'#64748B'}},
grid:{left:66,right:ix?62:30,top:38,bottom:56},
xAxis:{type:'category',data:eq.dates,axisLabel:{color:'#64748B',
formatter:v=>String(v).slice(0,7)}},
yAxis:[
{type:'value',scale:true,name:'净值',axisLabel:{color:'#64748B'},
splitLine:{lineStyle:{color:'#E9EEF6'}}},
...(ix ? [{type:'value',scale:true,name:'指数点位',position:'right',
axisLabel:{color:'#64748B',formatter:v=>num(v,0)},
nameTextStyle:{color:'#64748B'},splitLine:{show:false}}] : []),
],
dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:12}],
series,
});
if (note) {
if (!ix) note.textContent = '';
else if (!ix.covered)
note.innerHTML = '<span class="badge warn">该指数在本次回测区间内没有行情</span>';
else note.textContent =
`${ix.name} ${ix.code} · 覆盖 ${ix.points}/${eq.dates.length} 个交易日`;
}
}
async function mountBacktestDetail(runId) {
// 持仓面板与净值曲线并行加载:持仓查询较慢,不想拖住曲线
pfState.runId = runId;
loadPortfolio(null);
// 指数下拉:拿不到列表也不该挡住净值曲线本身
eqState.runId = runId;
try { eqState.indices = (await api('indices')).items || []; }
catch (e) { eqState.indices = []; }
const def = eqState.indices.find(x => x.is_default) || eqState.indices[0];
eqState.index = def ? def.code : '';
const sel = document.getElementById('eq-index');
if (sel) {
sel.innerHTML = eqState.indices.map(x =>
`<option value="${esc(x.code)}"${x.code === eqState.index ? ' selected' : ''}>`
+ `${esc(x.name)}(${esc(x.code)})</option>`).join('')
+ `<option value=""${eqState.index ? '' : ' selected'}>不叠加</option>`;
}
await mountEquityChart(runId, eqState.index);
const t = await api(`backtests/${runId}/trades?size=200`);
document.getElementById('trade-count').textContent = t.total + ' 笔';
const host = document.getElementById('trades');
if (!t.items.length) { host.innerHTML = '<div class="empty">无成交记录</div>'; }
else {
host.innerHTML = `<div class="tw"><table class="data">
<thead><tr><th>#</th><th class="l">代码</th><th>信号日</th><th>成交日</th>
<th>方向</th><th>价格</th><th>数量</th><th>金额</th><th>费用</th>
<th>已实现盈亏</th><th>持仓天数</th><th class="l">触发理由</th></tr></thead>
<tbody>${t.items.map((x,i)=>`<tr>
<td class="num">${i+1}</td>
<td class="l"><a href="#/backtests/${esc(runId)}/stocks/${esc(x.symbol)}" class="mono">${esc(x.symbol)}</a></td>
<td class="num">${esc(x.signal_date)}</td><td class="num">${esc(x.execution_date)}</td>
<td><span class="badge ${x.side==='BUY'?'gain':'loss'}">${esc(x.side)}</span></td>
<td class="num">${num(x.price,3)}</td><td class="num">${num(x.quantity,0)}</td>
<td class="num">${num(x.amount,0)}</td><td class="num">${num(x.total_cost,2)}</td>
<td class="num ${sign(x.realized_pnl)}">${x.realized_pnl==null?'—':num(x.realized_pnl,0)}</td>
<td class="num">${x.holding_days ?? '—'}</td>
<td class="l small">${esc(x.reason_text)}</td></tr>`).join('')}
</tbody></table></div>
${t.total > t.items.length ? `<div class="pager">仅显示前 ${t.items.length} / ${t.total} 笔</div>` : ''}`;
}
try {
const sk = await api(`backtests/${runId}/signals`);
if (sk.items && sk.items.length) {
document.getElementById('skips-card').style.display = '';
document.getElementById('skips').innerHTML = `<div class="tw"><table class="data">
<thead><tr><th class="l">代码</th><th>信号日</th><th>类型</th><th>分位</th>
<th class="l">未成交原因</th></tr></thead>
<tbody>${sk.items.map(s=>`<tr>
<td class="l mono">${esc(s.symbol)}</td><td class="num">${esc(s.signal_date)}</td>
<td><span class="badge info">${esc(s.signal_type)}</span></td>
<td class="num">${num(s.yield_percentile,1)}%</td>
<td class="l small">${esc(s.skip_label || '—')}</td></tr>`).join('')}
</tbody></table></div>`;
}
} catch (e) { /* 未成交信号是可选信息,失败不影响主流程 */ }
}
/* ---------------- 回测详情:持仓明细 ---------------- */
const pfState = {runId: null, dates: [], cursor: null, loadedFor: null};
// 日期清单在进入回测时取一次即可 —— 曾在每次切日期时重复拉取,
// 单次 460KB,翻页几下就很浪费。
async function ensurePfDates(runId) {
if (pfState.loadedFor === runId && pfState.dates.length) return pfState.dates;
const d = await api(`backtests/${runId}/position-dates`);
pfState.dates = d.dates || [];
pfState.loadedFor = runId;
return pfState.dates;
}
async function loadPortfolio(date) {
const host = document.getElementById('pf-body');
if (!host) return;
host.innerHTML = '<div class="loading">加载中…</div>';
try {
const q = date ? `?date=${encodeURIComponent(date)}` : '';
const d = await api(`backtests/${pfState.runId}/portfolio${q}`);
await ensurePfDates(pfState.runId);
pfState.cursor = d.date;
const el = document.getElementById('pf-date');
if (el) el.value = d.date;
const badge = document.getElementById('pf-badge');
if (badge) badge.textContent = d.adjusted
? `请求 ${d.requested_date} → 最近交易日 ${d.date}` : d.date;
const rng = document.getElementById('pf-range');
if (rng) rng.textContent = `可选区间 ${d.range.start} ~ ${d.range.end}(${d.range.count} 个交易日)`;
const e = d.equity, sm = d.summary;
const rows = d.positions.map((x, i) => `<tr>
<td class="num">${i + 1}</td>
<td class="l"><a href="#/backtests/${esc(d.run_id)}/stocks/${esc(x.symbol)}" class="mono">${esc(x.symbol)}</a></td>
<td class="l">${esc(x.name || '')}</td>
<td class="l small muted">${esc(x.industry || '')}</td>
<td class="num">${num(x.quantity, 0)}</td>
<td class="num">${num(x.avg_cost, 3)}</td>
<td class="num">${num(x.close, 3)}</td>
<td class="num">${num(x.market_value, 0)}</td>
<td class="num">${pct(x.weight)}</td>
<td class="num ${sign(x.unrealized_pnl)}">${num(x.unrealized_pnl, 0)}</td>
<td class="num ${sign(x.pnl_pct)}">${pct(x.pnl_pct)}</td>
<td class="num">${x.holding_days ?? '—'}</td>
</tr>`).join('');
host.innerHTML = `
<div class="kpi-grid" style="margin-bottom:14px">
<div class="kpi"><div class="label">总资产</div><div class="value">${yi(e.total_value)}</div>
<div class="note">净值 ${num(e.nav, 4)}</div></div>
<div class="kpi"><div class="label">现金</div><div class="value">${yi(e.cash)}</div>
<div class="note">占比 ${pct(e.total_value ? e.cash / e.total_value : null)}</div></div>
<div class="kpi"><div class="label">持仓市值</div><div class="value">${yi(e.position_value)}</div>
<div class="note">${sm.count} 只</div></div>
<div class="kpi"><div class="label">浮动盈亏</div>
<div class="value ${sign(sm.unrealized_pnl)}">${yi(sm.unrealized_pnl)}</div>
<div class="note">成本 ${yi(sm.cost)} · ${pct(sm.unrealized_pnl_pct)}</div></div>
<div class="kpi"><div class="label">当日涨跌</div>
<div class="value ${sign(e.daily_return)}">${pct(e.daily_return)}</div>
<div class="note">累计 ${pct(e.cum_return)}</div></div>
<div class="kpi"><div class="label">回撤</div>
<div class="value ${e.drawdown < 0 ? 'loss' : ''}">${pct(e.drawdown)}</div>
<div class="note">相对历史高点</div></div>
</div>
${d.positions.length ? `<div class="tw"><table class="data">
<thead><tr><th>#</th><th class="l">代码</th><th class="l">名称</th><th class="l">行业</th>
<th>股数</th><th>成本</th><th>收盘</th><th>市值</th><th>权重</th>
<th>浮动盈亏</th><th>收益率</th><th>持仓天数</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr><td colspan="7" class="l"><b>合计</b></td>
<td class="num"><b>${num(sm.market_value, 0)}</b></td>
<td class="num"><b>${pct(e.total_value ? sm.market_value / e.total_value : null)}</b></td>
<td class="num ${sign(sm.unrealized_pnl)}"><b>${num(sm.unrealized_pnl, 0)}</b></td>
<td class="num ${sign(sm.unrealized_pnl_pct)}"><b>${pct(sm.unrealized_pnl_pct)}</b></td>
<td></td></tr></tfoot>
</table></div>` : '<div class="empty">该日空仓(100% 现金)</div>'}
<div class="callout" style="margin-top:12px">
点击任意股票代码可打开<b>该股在本次回测中的买卖点与趋势图</b>。
</div>`;
} catch (err) {
host.innerHTML = `<div class="callout fail">加载失败:${esc(err.message)}</div>`;
}
}
function shiftPortfolio(delta) {
if (!pfState.dates.length) return loadPortfolio(null);
if (delta === 0) return loadPortfolio(null);
const i = pfState.dates.indexOf(pfState.cursor);
const j = Math.max(0, Math.min(pfState.dates.length - 1,
(i < 0 ? pfState.dates.length - 1 : i) + delta));
loadPortfolio(pfState.dates[j]);
}
/* ---------------- 视图:回测内个股买卖点 ---------------- */
const stState = {runId: null, symbol: null, data: null, shown: {}};
async function viewBacktestStock(runId, symbol) {
let d;
try { d = await api(`backtests/${runId}/stocks/${encodeURIComponent(symbol)}`); }
catch (e) {
return `<div class="crumb"><a href="#/backtests/${esc(runId)}">返回回测</a></div>
<div class="callout fail"><b>无法加载:</b>${esc(e.message)}</div>`;
}
const st = d.stats;
const i = d.info;
return `
<div class="crumb"><a href="#/backtests">回测</a><span>/</span>
<a href="#/backtests/${esc(runId)}">${esc(runId.slice(0, 12))}…</a><span>/</span>个股</div>
<h1 class="page">${esc(i.name || symbol)}
<span class="mono muted" style="font-size:15px">${esc(symbol)}</span></h1>
<div class="page-sub">${esc(i.industry || '—')} · 区间 ${esc(d.range.start)} ~ ${esc(d.range.end)}
${d.range.downsampled ? `(原始 ${d.range.points} 点,已降采样显示)` : `(${d.range.points} 个交易日)`}</div>
<div class="kpi-grid">
<div class="kpi"><div class="label">成交笔数</div><div class="value">${st.trade_count}</div>
<div class="note">买 ${st.buy_count} / 卖 ${st.sell_count}</div></div>
<div class="kpi"><div class="label">买入金额</div><div class="value">${yi(st.buy_amount)}</div>
<div class="note">卖出 ${yi(st.sell_amount)}</div></div>
<div class="kpi"><div class="label">已实现盈亏</div>
<div class="value ${sign(st.realized_pnl)}">${yi(st.realized_pnl)}</div>
<div class="note">仅平仓部分</div></div>
<div class="kpi"><div class="label">交易费用</div><div class="value">${num(st.total_fees, 2)}</div>
<div class="note">佣金+印花税+过户费</div></div>
<div class="kpi"><div class="label">首笔 / 末笔</div>
<div class="value" style="font-size:14px">${esc(st.first_trade || '—')}<br>${esc(st.last_trade || '—')}</div>
<div class="note">成交日期</div></div>
</div>
<div class="card">
<h2>趋势与买卖点</h2>
<div class="toolbar">
<span class="small muted">显示指标(勾几项就是几联图,自上而下排列):</span>
${d.available_series.map(k => `<label class="check">
<input type="checkbox" class="st-ser" value="${k}"
${['close','dv_yield','pe_ttm','roe'].includes(k) ? 'checked' : ''}>
${esc(SERIES_LABEL[k] || k)}</label>`).join('')}
<button data-act="st-apply" style="margin-left:auto">应用</button>
</div>
<div id="c_stock" class="chart"></div>
<div id="c_stock_note"></div>
<div class="callout" style="margin-top:12px">
每个指标<b>独占一个面板</b>(N 联图):时间轴、缩放与十字光标上下联动,
便于对照同一时点的估值与质量读数。<b>▲ 买入 / ▼ 卖出</b> 同时标注在<b>每个面板</b>上,
位置取该指标在成交日的取值;鼠标悬停可一次看全部指标与成交的价格、股数、金额。
股息率为 PIT-TTM 口径(TTM 每股分红 ÷ 不复权收盘价),
ROE 按<b>公告日</b>对齐成阶梯线(不插值,避免未来函数)。
</div>
</div>
<div class="card">
<h2>逐笔成交明细 <span class="badge info">${st.trade_count}</span></h2>
${st.trade_count ? `<div class="tw"><table class="data">
<thead><tr><th>#</th><th>信号日</th><th>成交日</th><th>方向</th>
<th>价格</th><th>股数</th><th>金额</th><th>佣金</th><th>印花税</th><th>过户费</th>
<th>费用合计</th><th>滑点成本</th><th>已实现盈亏</th><th>持仓天数</th>
<th class="l">触发理由</th></tr></thead>
<tbody>${d.trades.map((t, i2) => `<tr>
<td class="num">${i2 + 1}</td>
<td class="num">${esc(t.signal_date)}</td><td class="num">${esc(t.execution_date)}</td>
<td><span class="badge ${t.side === 'BUY' ? 'gain' : 'loss'}">${t.side === 'BUY' ? '买入' : '卖出'}</span></td>
<td class="num">${num(t.price, 3)}</td><td class="num">${num(t.quantity, 0)}</td>
<td class="num">${num(t.amount, 0)}</td>
<td class="num">${num(t.commission, 2)}</td><td class="num">${num(t.stamp_tax, 2)}</td>
<td class="num">${num(t.transfer_fee, 2)}</td><td class="num">${num(t.total_cost, 2)}</td>
<td class="num">${num(t.slippage_cost, 2)}</td>
<td class="num ${sign(t.realized_pnl)}">${t.realized_pnl == null ? '—' : num(t.realized_pnl, 0)}</td>
<td class="num">${t.holding_days ?? '—'}</td>
<td class="l small">${esc(t.reason_text)}</td></tr>`).join('')}
</tbody></table></div>` : '<div class="empty">该股在本次回测中没有成交</div>'}
</div>`;
}
const SERIES_LABEL = {close: '股价', dv_yield: '股息率', pe_ttm: 'PE(TTM)',
pb: 'PB', roe: 'ROE', drawdown: '回撤'};
// 面板排列顺序:股价永远在首位(买卖点以成交价标注),其余按 估值 → 质量 → 风险 排。
// 顺序固定而非按勾选先后,避免同一组指标因勾选次序不同而换位置。
const SERIES_ORDER = ['close', 'dv_yield', 'pe_ttm', 'pb', 'roe', 'drawdown'];
const SERIES_UNIT = {close: '元', dv_yield: '', pe_ttm: '倍', pb: '倍',
roe: '', drawdown: ''};
// 轴刻度 / 十字光标标签:比率型指标(股息率、回撤)在此转为 %
const SERIES_TICK = {close: v => num(v, 2), dv_yield: v => (v * 100).toFixed(2) + '%',
pe_ttm: v => num(v, 1), pb: v => num(v, 1), roe: v => num(v, 1) + '%',
drawdown: v => (v * 100).toFixed(0) + '%'};
// 提示框数值(带单位)
const SERIES_TEXT = {close: v => num(v, 2) + ' 元', dv_yield: v => pct(v, 2),
pe_ttm: v => num(v, 2), pb: v => num(v, 2), roe: v => num(v, 2) + '%',
drawdown: v => pct(v, 2)};
function mountBacktestStock(runId, symbol) {
const render = async () => {
const host = document.getElementById('c_stock');
if (!host) return;
const shown = [...document.querySelectorAll('.st-ser:checked')]
.map(x => x.value)
.sort((a, b) => SERIES_ORDER.indexOf(a) - SERIES_ORDER.indexOf(b));
if (!shown.length) {
host.style.height = '140px';
host.innerHTML = '<div class="empty">请至少勾选一个指标</div>';
const n0 = document.getElementById('c_stock_note');
if (n0) n0.innerHTML = '';
return;
}
host.innerHTML = '<div class="loading">加载中…</div>';
const d = await api(`backtests/${runId}/stocks/${encodeURIComponent(symbol)}`
+ `?series=${shown.join(',')}`);
stState.data = d;
host.innerHTML = '';
paintStockPanels(host, d, shown);
};
render().catch(e => {
const host = document.getElementById('c_stock');
if (host) host.innerHTML = `<div class="callout fail">图表加载失败:${esc(e.message)}</div>`;
const note = document.getElementById('c_stock_note');
if (note) note.innerHTML = '';
});
return render;
}
/** 把选中的指标画成 N 联图:每个指标一个 grid,共用一个时间轴与缩放。 */
function paintStockPanels(host, d, shown) {
const N = shown.length;
const TOP = 46; // 顶部:买卖点图例 + 首个面板标题
const GAP = 34; // 面板间距(容纳下一个面板标题)
const BOTTOM = 54; // 末面板的 x 轴标签 + dataZoom 滑条
const PANEL_H = N <= 2 ? 170 : (N <= 4 ? 132 : 112);
const gridTop = i => TOP + i * (PANEL_H + GAP);
host.style.height = (gridTop(N - 1) + PANEL_H + BOTTOM) + 'px';
const dateIndex = new Map(d.dates.map((dt, i) => [dt, i]));
const idxOf = date => (dateIndex.has(date) ? dateIndex.get(date) : -1);
const tradesOn = new Map();
d.trades.forEach(t => {
if (!tradesOn.has(t.execution_date)) tradesOn.set(t.execution_date, []);
tradesOn.get(t.execution_date).push(t);
});
// 成交日可能落在价格区间之外(实测 000338.SZ 的卖出在区间最后一天之后一天),
// 直接用日期当类目会把这笔成交整笔丢掉。这里把这些日期按序补进横轴,
// 让股价面板仍能按成交价标出买卖点。
const extraDates = [...new Set(d.trades.map(t => t.execution_date))]
.filter(dt => !dateIndex.has(dt)).sort();
const axisDates = d.dates.slice();
extraDates.forEach(dt => {
let lo = 0, hi = axisDates.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (axisDates[mid] < dt) lo = mid + 1; else hi = mid;
}
axisDates.splice(lo, 0, dt);
});
const titles = [], grids = [], xAxis = [], yAxis = [], series = [];
shown.forEach((k, i) => {
const color = COLORS[i % COLORS.length];
const vals = d.series[k] || [];
const top = gridTop(i);
grids.push({left: 76, right: 26, top, height: PANEL_H});
xAxis.push({
type: 'category', data: axisDates, gridIndex: i,
// 两端留 1% 空隙:首/末成交日的三角标不会被画到 grid 外面切掉
boundaryGap: ['1%', '1%'],
axisTick: {show: false},
axisLine: {show: i === N - 1, lineStyle: {color: '#DBEAFE'}},
// 只有最下面的面板显示日期,其余靠十字光标对齐读取
axisLabel: {show: i === N - 1, color: '#64748B', fontSize: 11,
formatter: v => String(v).slice(0, 7)},
axisPointer: {label: {show: i === N - 1}},
});
yAxis.push({
// scale:true 让轴随数据取值;splitNumber 不能太小 —— 取 3 时
// 「nice」刻度会把价格轴整到 0~120,趋势被压扁(实测过)。
type: 'value', gridIndex: i, scale: true, splitNumber: 4,
axisLabel: {color: '#64748B', fontSize: 11, formatter: SERIES_TICK[k]},
splitLine: {lineStyle: {color: '#E9EEF6'}},
axisPointer: {label: {formatter: p => SERIES_TICK[k](p.value)}},
});
titles.push({
left: 76, top: top - 20,
text: `{n|${SERIES_LABEL[k] || k}}`
+ (SERIES_UNIT[k] ? `{u|(${SERIES_UNIT[k]})}` : '')
+ `{v|最新 ${SERIES_TEXT[k](vals[vals.length - 1])}}`,
textStyle: {rich: {
n: {fontSize: 12, fontWeight: 600, color},
u: {fontSize: 11, color: '#94A3B8'},
v: {fontSize: 11, color: '#94A3B8', padding: [0, 0, 0, 10]},
}},
});
series.push({
name: SERIES_LABEL[k] || k, type: 'line', xAxisIndex: i, yAxisIndex: i,
// 指标序列按补过日期的横轴对齐(多出来的位置为 null,折线自然断开)
data: k === 'close' && !extraDates.length
? vals : axisDates.map(dt => {
const j = dateIndex.get(dt);
return j === undefined ? null : vals[j];
}),
showSymbol: false, sampling: 'lttb', z: 5,
lineStyle: {width: k === 'close' ? 1.6 : 1.3, color},
itemStyle: {color},
...(k === 'dv_yield' ? {areaStyle: {opacity: 0.08, color}} : {}),
});
// 买卖点画在**每个**面板上:取该指标在成交日的取值,
// 这样能直接看出「买在多少股息率 / 多少 PE」。
// 股价面板用成交价(含滑点),与「▲▼ 标在成交价上」一致;
// 区间外的成交日只有成交价、没有指标值,因此只画在股价面板。
[['BUY', '买入', COLORS[4], 'triangle', k === 'close' ? 12 : 8],
['SELL', '卖出', COLORS[3], 'diamond', k === 'close' ? 12 : 8]]
.forEach(([side, cn, c, sym, size]) => {
const pts = d.trades.filter(t => t.side === side).map(t => {
if (k === 'close') return t.price == null ? null : [t.execution_date, t.price];
const j = dateIndex.get(t.execution_date);
const v = j === undefined ? null : vals[j];
return v == null ? null : [t.execution_date, v];
}).filter(Boolean);
if (!pts.length) return;
series.push({
name: cn, type: 'scatter', xAxisIndex: i, yAxisIndex: i, z: 20,
symbol: sym, symbolSize: size, itemStyle: {color: c},
data: pts, tooltip: {show: false}, // 统一由 axis 提示框呈现
});
});
});
const xIdx = shown.map((_, i) => i);
chart('c_stock', {
animation: false,
// 任意面板悬停都弹同一份「全指标 + 当日成交」读数
tooltip: {
trigger: 'axis', confine: true,
axisPointer: {type: 'cross', label: {backgroundColor: '#475569'}},
formatter: params => {
const p = Array.isArray(params) ? params[0] : params;
if (!p) return '';
const date = p.axisValue, idx = idxOf(date);
const head = `<div style="font-weight:600;margin-bottom:4px">${esc(date)}</div>`;
const rows = idx < 0
? `<div style="color:#94A3B8;font-size:12px">该日超出指标数据区间,仅此处的成交记录</div>`
: shown.map((k, i) => `
<div style="display:flex;gap:16px;justify-content:space-between;line-height:1.7">
<span style="color:${COLORS[i % COLORS.length]}">● ${esc(SERIES_LABEL[k] || k)}</span>
<b style="font-family:monospace">${esc(SERIES_TEXT[k]((d.series[k] || [])[idx]))}</b>
</div>`).join('');
const trs = (tradesOn.get(date) || []).map(t => `
<div style="margin-top:6px;padding-top:6px;border-top:1px dashed #CBD5E1">
<b style="color:${t.side === 'BUY' ? COLORS[4] : COLORS[3]}">
${t.side === 'BUY' ? '▲ 买入' : '▼ 卖出'}</b>
<span style="font-family:monospace">${num(t.price, 3)} 元</span> ·
${num(t.quantity, 0)} 股 · ${num(t.amount, 0)} 元<br/>
<span style="color:#64748B;font-size:12px">${esc(t.reason_text)}</span>
</div>`).join('');
return `<div style="max-width:340px;white-space:normal">${head}${rows}${trs}</div>`;
},
},
legend: {data: ['买入', '卖出'], top: 2, right: 8, itemWidth: 12,
itemHeight: 8, itemGap: 14,
textStyle: {color: '#64748B', fontSize: 11}},
axisPointer: {link: [{xAxisIndex: 'all'}]}, // 十字光标跨面板对齐
title: titles, grid: grids, xAxis, yAxis,
dataZoom: [{type: 'inside', xAxisIndex: xIdx},
{type: 'slider', xAxisIndex: xIdx, height: 16, bottom: 10,
borderColor: '#DBEAFE', fillerColor: 'rgba(30,64,175,.08)',
handleStyle: {color: COLORS[0]},
textStyle: {color: '#64748B', fontSize: 10}}],
series,
});
// 区间外的成交只画得出股价面板,明确说明,避免读者以为图上没卖点就是没卖过
const note = document.getElementById('c_stock_note');
if (note) {
const items = extraDates.map(dt => (tradesOn.get(dt) || []).map(t =>
`${dt} ${t.side === 'BUY' ? '买入' : '卖出'} ${num(t.price, 3)} 元`).join('、'))
.filter(Boolean);
note.innerHTML = items.length ? `<div class="callout warn" style="margin-top:12px">
有 ${items.length} 笔成交发生在指标区间(${esc(d.range.start)} ~ ${esc(d.range.end)})之外:
${esc(items.join(';'))}。<br/>
这些成交只能按<b>成交价</b>标在「股价」面板上,
股息率 / PE 等面板没有对应日期的取值,因此不标注(悬停对应日期仍可看到成交信息)。
</div>` : '';
}
}
/* ---------------- 视图:Walk-forward ---------------- */
async function viewWalkforwards() {
const d = await api('walkforwards');
return `
<h1 class="page">Walk-forward 样本外验证</h1>
<div class="page-sub">每个窗口用训练段校准阈值、测试段冻结参数,是判断策略是否真正有效的核心依据。</div>
<div class="callout warn">
<b>判读要点</b>:单条路径的全期回测会系统性高估策略。
请以<b>样本外均值</b>与<b>稳定性</b>(均值/标准差,&lt;1 表示窗口间差异大于均值本身)为准。
</div>
${d.items.length ? `<div class="rec-list">${d.items.map(wfRec).join('')}</div>`
: `<div class="empty">还没有 Walk-forward 记录。<br>
运行 <code class="mono">python -m hdiv backtest --mode walkforward</code>(约 25 分钟)。</div>`}`;
}
function wfRec(w) {
const o = w.oos || {};
const m = o.mean_return, wr = o.win_rate, dd = o.worst_drawdown;
return `<div class="rec">
<div class="rec-main">
<div class="rec-title">
<a href="#/walkforwards/${esc(w.wf_id)}">${esc(w.title)}</a>
<span class="badge info">${esc(w.status || 'OK')}</span>
</div>
<div class="rec-meta">
<span class="mono">${esc((w.created_at || '').slice(0, 16))}</span>
<span>步进 ${w.step_months} 月</span>
<span>数据版本 <span class="mono">${esc((w.data_version || '').slice(0, 10))}</span></span>
</div>
${m != null ? `<div class="rec-metrics">
<span class="rec-metric">样本外均值<b class="${sign(m)}">${pct(m)}</b></span>
<span class="rec-metric">样本外胜率<b>${pct(wr, 1)}</b></span>
<span class="rec-metric">最差回撤<b class="loss">${pct(dd)}</b></span>
<span class="rec-metric">有效窗口<b>${o.sample_count ?? '—'}/${o.window_count ?? '—'}</b></span>
</div>` : ''}
</div>
<div class="rec-actions">
<button class="primary" data-act="open" data-id="${esc(w.wf_id)}" data-prefix="walkforwards">
打开逐窗口结果</button>
</div>
</div>`;
}
async function viewWalkforwardDetail(wfId) {
let d;
try { d = await api(`walkforwards/${wfId}`); }
catch (e) {
return `<div class="crumb"><a href="#/walkforwards">样本外</a></div>
<div class="callout fail"><b>无法加载:</b>${esc(e.message)}</div>`;
}
const s = d.summary;
const rows = d.windows.map(w => {
const i = w.in_sample, o = w.out_of_sample;
const excess = (i.total_return != null && o.total_return != null) ? null : null;
return `<tr>
<td class="num">#${w.window_index}</td>
<td class="num">${esc(w.train_start)} ~ ${esc(w.train_end)}</td>
<td class="num">${esc(w.test_start)} ~ ${esc(w.test_end)}</td>
<td class="num">${num(i.total_return != null ? i.total_return * 100 : null, 2)}%</td>
<td class="num"><b>${num(i.cagr != null ? i.cagr * 100 : null, 2)}%</b></td>
<td class="num">${num(i.sharpe, 2)}</td>
<td class="num ${sign(o.total_return)}"><b>${num(o.total_return != null ? o.total_return * 100 : null, 2)}%</b></td>
<td class="num ${sign(o.cagr)}">${num(o.cagr != null ? o.cagr * 100 : null, 2)}%</td>
<td class="num">${num(w.benchmark_return != null ? w.benchmark_return * 100 : null, 2)}%</td>
<td class="num ${sign(w.excess_return)}"><b>${num(w.excess_return != null ? w.excess_return * 100 : null, 2)}pp</b></td>
<td class="num loss">${num(o.max_drawdown != null ? o.max_drawdown * 100 : null, 2)}%</td>
<td class="num">${num(o.sharpe, 2)}</td>
<td class="num">${num(o.trade_count, 0)}</td>
<td class="num">P${num(w.frozen_params.entry_yield_percentile, 0)}</td>
</tr>`;
}).join('');
return `
<div class="crumb"><a href="#/walkforwards">样本外</a><span>/</span>${esc(d.strategy_id)}</div>
<h1 class="page">${esc(d.title)}</h1>
<div class="page-sub">
${esc(d.scheme)} · 训练 ${d.train_years} 年 / 测试 ${d.test_years} 年 · 步进 ${d.step_months} 月 ·
<span class="mono">${esc(d.wf_id)}</span>
</div>
<div class="kpi-grid">
<div class="kpi"><div class="label">样本外收益均值</div>
<div class="value ${sign(s.oos_mean)}">${pct(s.oos_mean)}</div>
<div class="note">中位数 ${pct(s.oos_median)}</div></div>
<div class="kpi"><div class="label">样本外胜率</div>
<div class="value">${pct(s.oos_win_rate, 1)}</div>
<div class="note">${s.oos_returns.filter(x => x > 0).length} / ${s.oos_returns.length} 个窗口为正</div></div>
<div class="kpi"><div class="label">基准均值(沪深300)</div>
<div class="value">${pct(s.benchmark_mean)}</div>
<div class="note">同期被动持有</div></div>
<div class="kpi"><div class="label">超额收益均值</div>
<div class="value ${sign(s.excess_mean)}">${num(s.excess_mean != null ? s.excess_mean * 100 : null, 2)}pp</div>
<div class="note">超额胜率 ${pct(s.excess_win_rate, 1)}</div></div>
<div class="kpi"><div class="label">稳定性</div>
<div class="value ${s.oos_stability != null && s.oos_stability < 0 ? 'loss' : ''}">${num(s.oos_stability, 2)}</div>
<div class="note">均值/标准差,&lt;1 表示结论不稳</div></div>
<div class="kpi"><div class="label">最差回撤</div>
<div class="value loss">${pct(s.oos_worst_drawdown)}</div>
<div class="note">样本外最深</div></div>
<div class="kpi"><div class="label">窗口数</div>
<div class="value">${s.window_count}</div>
<div class="note">${d.scheme} 滚动</div></div>
</div>
<div class="card">
<h2>样本内 vs 样本外</h2>
<div id="c_wf" class="chart"></div>
<div class="callout" style="margin-top:12px">
若<b>样本内收益明显高于样本外</b>,说明阈值是在训练段「拟合」出来的,
样本外无法复现 —— 这就是过拟合的直接证据。<br>
<b>超额 = 策略 − 基准(沪深300)</b>。本策略的典型形态是
<b>牛市跑输、熊市跑赢</b>:请结合当年的市场环境判读,不要只看均值。<br>
<b>注意区间长度</b>:训练段 5 年、测试段 1 年,因此上图统一用<b>年化</b>口径。
直接比两者的累计收益会得出「样本内远高于样本外」的错误印象。
</div>
</div>
<div class="card">
<h2>逐窗口明细</h2>
<div class="tw"><table class="data">
<thead>
<tr><th colspan="6" class="l" style="text-align:center">训练段(5 年 · 校准参照分布)</th>
<th colspan="7" class="l" style="text-align:center;border-left:2px solid #DBEAFE">测试段(1 年 · 冻结参数)</th></tr>
<tr><th>窗口</th><th>训练区间</th><th>测试区间</th>
<th>收益</th><th>CAGR</th><th>Sharpe</th>
<th style="border-left:2px solid #DBEAFE">收益</th><th>年化</th><th>基准</th>
<th>超额</th><th>最大回撤</th>
<th>Sharpe</th><th>成交</th><th>冻结阈值</th></tr>
</thead>
<tbody>${rows}</tbody>
</table></div>
<div class="callout" style="margin-top:12px">
<b>冻结阈值</b>是该窗口在训练段校准出、并在测试段强制沿用的买入分位 ——
各窗口若差异很大,说明策略对参数不稳定。
</div>
</div>
<div class="card">
<h2>可复现性</h2>
<div class="tw"><table class="data"><tbody>
<tr><td class="l">策略</td><td class="l mono">${esc(d.strategy_id)} v${esc(d.strategy_version)}</td></tr>
<tr><td class="l">配置指纹</td><td class="l mono">${esc(d.config_hash || '—')}</td></tr>
<tr><td class="l">数据版本</td><td class="l mono">${esc(d.data_version || '—')}</td></tr>
<tr><td class="l">代码版本</td><td class="l mono">${esc(d.code_version || '—')}</td></tr>
</tbody></table></div>
</div>`;
}
function mountWalkforwardDetail(d) {
const w = d.windows || [];
if (!w.length) return;
chart('c_wf', {
tooltip: {trigger: 'axis', axisPointer: {type: 'shadow'}},
legend: {top: 0, textStyle: {color: '#64748B'}},
grid: {left: 64, right: 30, top: 38, bottom: 76},
xAxis: {type: 'category',
data: w.map(x => `#${x.window_index}\n${String(x.test_start).slice(0, 7)}`),
axisLabel: {color: '#64748B', fontSize: 10, lineHeight: 13}},
yAxis: {type: 'value', name: '年化收益 %',
axisLabel: {color: '#64748B', formatter: v => v + '%'},
splitLine: {lineStyle: {color: '#E9EEF6'}}},
series: [
// 用**年化**而非累计:训练段 5 年、测试段 1 年,
// 直接比累计收益是不同长度区间的比较,会得出误导性结论。
{name: `样本内年化(${w[0] ? '' : ''}5年)`, type: 'bar', barMaxWidth: 22,
itemStyle: {color: COLORS[2]},
data: w.map(x => x.in_sample.cagr != null
? +(x.in_sample.cagr * 100).toFixed(2) : null)},
{name: '样本外年化(1年)', type: 'bar', barMaxWidth: 22,
itemStyle: {color: COLORS[0]},
data: w.map(x => x.out_of_sample.cagr != null
? +(x.out_of_sample.cagr * 100).toFixed(2) : null)},
{name: '基准(年内)', type: 'bar', barMaxWidth: 22,
itemStyle: {color: COLORS[3]},
data: w.map(x => x.benchmark_return != null
? +(x.benchmark_return * 100).toFixed(2) : null)},
{name: '超额', type: 'line', symbolSize: 7, lineStyle: {width: 1.6, color: COLORS[1]},
itemStyle: {color: COLORS[1]},
data: w.map(x => x.excess_return != null
? +(x.excess_return * 100).toFixed(2) : null)},
{name: '零轴', type: 'line', data: [], markLine: {
silent: true, symbol: 'none',
lineStyle: {color: '#94A3B8', type: 'dashed', width: 1},
data: [{yAxis: 0}]}},
],
});
}
/* ---------------- 视图:归档 ---------------- */
async function viewArchive() {
const [us, bs] = await Promise.all([
api('universes?include_archived=1&include_deleted=1'),
api('backtests?include_archived=1&include_deleted=1')
]);
const aus = us.items.filter(x => x.state !== 'active');
const abs = bs.items.filter(x => x.state !== 'active');
return `
<h1 class="page">归档与已删除</h1>
<div class="page-sub">归档与软删除的记录仍完整保留在数据库中,可随时恢复。</div>
<div class="card"><h2>股票池筛选 <span class="badge info">${aus.length}</span></h2>
${aus.length ? `<div class="rec-list">${aus.map(recUniverse).join('')}</div>`
: '<div class="empty">没有归档或删除的筛选记录。</div>'}</div>
<div class="card"><h2>回测 <span class="badge info">${abs.length}</span></h2>
${abs.length ? `<div class="rec-list">${abs.map(recBacktest).join('')}</div>`
: '<div class="empty">没有归档或删除的回测记录。</div>'}</div>`;
}
/* ---------------- 路由 ---------------- */
let currentPath = '';
function parseHash() {
const raw = location.hash.replace(/^#/, '') || '/';
const [path, qs] = raw.split('?');
return {parts: path.split('/').filter(Boolean), q: new URLSearchParams(qs || '')};
}
async function render() {
const view = document.getElementById('view');
const {parts, q} = parseHash();
const path = parts.join('/');
if (path === currentPath) return;
currentPath = path;
disposeCharts();
document.querySelectorAll('.nav a').forEach(a => {
const key = a.dataset.nav;
const on = (key === 'home' && !parts.length) ||
(key === 'universes' && parts[0] === 'universes') ||
(key === 'backtests' && parts[0] === 'backtests') ||
(key === 'walkforwards' && parts[0] === 'walkforwards') ||
(key === 'archive' && parts[0] === 'archive');
a.classList.toggle('active', !!on);
});
view.innerHTML = '<div class="loading">加载中…</div>';
try {
let html = '', after = null;
if (!parts.length) html = await viewHome();
else if (parts[0] === 'universes' && parts.length === 1) html = await viewUniverses(q);
else if (parts[0] === 'universes' && parts.length === 2) {
const u = await api(`universes/${parts[1]}`);
html = await viewUniverseDetail(parts[1], q);
after = () => mountUniverseDetail(u);
}
else if (parts[0] === 'stocks' && parts.length === 2) {
const d = await api(`stocks/${encodeURIComponent(parts[1])}`).catch(() => null);
html = await viewStock(parts[1]);
if (d) after = () => mountStock(d);
}
else if (parts[0] === 'backtests' && parts.length === 1) html = await viewBacktests(q);
else if (parts[0] === 'backtests' && parts.length === 4 && parts[2] === 'stocks') {
html = await viewBacktestStock(parts[1], parts[3]);
after = () => { stState.runId = parts[1]; stState.symbol = parts[3];
mountBacktestStock(parts[1], parts[3]); };
}
else if (parts[0] === 'backtests' && parts.length === 2) {
html = await viewBacktestDetail(parts[1]);
after = () => mountBacktestDetail(parts[1]).catch(e => toast(e.message, true));
}
else if (parts[0] === 'walkforwards' && parts.length === 1)
html = await viewWalkforwards();
else if (parts[0] === 'walkforwards' && parts.length === 2) {
const d = await api(`walkforwards/${parts[1]}`);
html = await viewWalkforwardDetail(parts[1]);
after = () => mountWalkforwardDetail(d);
}
else if (parts[0] === 'archive') html = await viewArchive();
else html = `<div class="callout warn">未知页面:<span class="mono">${esc(path)}</span>
<br><a href="#/">返回概览</a></div>`;
view.innerHTML = html;
if (after) await after();
} catch (e) {
view.innerHTML = `<div class="callout fail"><b>加载失败:</b>${esc(e.message)}
<br><span class="small">请确认后端已启动(<code class="mono">python -m hdiv web</code>),
且 nginx 已把 <code class="mono">/api</code> 反代到该服务。</span></div>`;
}
window.scrollTo(0, 0);
}
/* ---------------- 交互(事件委托) ---------------- */
document.addEventListener('click', async e => {
const btn = e.target.closest('[data-act]');
if (btn) {
e.preventDefault();
const id = btn.dataset.id, act = btn.dataset.act, scope = btn.dataset.scope || 'u';
const base = scope === 'b' ? 'backtests' : 'universes';
try {
if (act === 'open') {
location.hash = `#/${btn.dataset.prefix || base}/${id}`; return;
}
if (act === 'rename') {
const cur = await api(`${base}/${id}`);
modal(scope === 'b' ? '命名回测记录' : '命名筛选记录',
[{key:'display_name', label:'名称', value: cur.display_name || ''},
{key:'notes', label:'备注', value: cur.notes || '', type:'textarea'}],
async v => {
await patch(`${base}/${id}`, v);
toast('已保存');
if (scope === 'b' && cur.universe_run_id)
location.hash = `#/universes/${cur.universe_run_id}`;
else if (scope === 'u' && location.hash.includes(id))
{ currentPath=''; render(); }
else { currentPath=''; render(); }
});
return;
}
if (act === 'archive') {
await patch(`${base}/${id}`, {archived: btn.dataset.to === '1'});
toast(btn.dataset.to === '1' ? '已归档' : '已取消归档');
currentPath = ''; render(); return;
}
if (act === 'delete') {
const del = btn.dataset.to === '1';
if (del && !confirm('确认删除这条记录?\n\n(软删除:记录会被隐藏,但数据仍完整保留,可随时恢复)')) return;
await patch(`${base}/${id}`, {deleted: del});
toast(del ? '已删除(可从「归档」页恢复)' : '已恢复');
currentPath = ''; render(); return;
}
if (act === 'pf-shift') {
shiftPortfolio(parseInt(btn.dataset.d, 10));
return;
}
if (act === 'st-apply') {
const host = document.getElementById('c_stock');
if (host) {
const shown = [...document.querySelectorAll('.st-ser:checked')].map(x => x.value);
if (!shown.length) { toast('请至少勾选一个指标', true); return; }
disposeCharts();
const {parts} = parseHash();
mountBacktestStock(parts[1], parts[3]);
}
return;
}
if (act === 'page') {
const {parts, q} = parseHash();
q.set('page', btn.dataset.p);
location.hash = `#/${parts.join('/')}?${q.toString()}`;
currentPath = ''; render(); return;
}
} catch (err) { toast(err.message, true); }
return;
}
// 列表页筛选控件
if (e.target.id === 'doSearch' || e.target.id === 'mgo') {
const {parts, q} = parseHash();
const qEl = document.getElementById('q') || document.getElementById('mq');
if (qEl) q.set('q', qEl.value.trim());
const fEl = document.getElementById('mfilter');
if (fEl) { q.set('passed', fEl.value); q.set('page', '1'); }
location.hash = `#/${parts.join('/')}${q.toString() ? '?' + q.toString() : ''}`;
currentPath = ''; render(); return;
}
});
document.addEventListener('change', e => {
if (e.target.id === 'pf-date') {
loadPortfolio(e.target.value);
return;
}
if (e.target.id === 'eq-index') {
eqState.index = e.target.value;
mountEquityChart(eqState.runId, eqState.index)
.catch(err => toast(err.message, true));
return;
}
if (e.target.id === 'incArch' || e.target.id === 'incDel') {
const {parts, q} = parseHash();
if (e.target.id === 'incArch') q.set('archived', e.target.checked ? '1' : '0');
if (e.target.id === 'incDel') q.set('deleted', e.target.checked ? '1' : '0');
location.hash = `#/${parts.join('/')}?${q.toString()}`;
currentPath = ''; render();
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.target.id === 'q' || e.target.id === 'mq')) {
document.getElementById(e.target.id === 'q' ? 'doSearch' : 'mgo').click();
}
});
window.addEventListener('hashchange', () => { currentPath = ''; render(); });
/* ---------------- 启动 ---------------- */
(async function boot() {
const st = document.getElementById('api-status');
try {
await api('health');
st.textContent = 'API 正常'; st.className = 'badge ok';
try {
const dc = await api('config/display');
Object.assign(FMT, dc);
document.documentElement.style.setProperty('--max-width', (dc.max_width || 1440) + 'px');
} catch (e) { /* 取不到就用默认精度,不影响使用 */ }
} catch (e) {
st.textContent = 'API 不可用'; st.className = 'badge fail';
}
document.getElementById('foot-note').textContent =
'数据来自数据库,页面不做任何指标计算 · 删除为软删除,数据不做物理清除';
render();
})();