Files
ggx/web/app.js
T
simon fce725e13c 初始提交:高股息策略研究与回测系统
从 Point-in-Time 股票筛选到统一 Web 前端的完整链路:
筛选 → 画像 → 策略 → 回测 → Walk-forward → 绩效分析 → 报告/前端。

架构
- 数据层与策略层分离;策略代码不写 SQL,只经 data/repo.py 取数
- 所有业务阈值集中在 config/*.yml,代码零硬编码(字段写错直接报错)
- 报告只做「run_id → SQL → 渲染」,不做任何计算,数字可追溯
- 前后端分离:output/ 静态站点 + hdiv web 提供的 REST API

数据安全
- 只增不删:SQL 钩子拦截 DELETE/DROP/TRUNCATE,并有源码扫描测试守护
- qlib 原有表只读,本项目数据写入 hd_ 前缀表
- 回补使用 INSERT IGNORE,保证既有行零改动
- .env 存密钥且已 gitignore;output/、logs/、.venv/ 不入库

交付物
- 30 张 hd_* 表、7 个 YAML 配置、283 项自动化测试
- 统一 Web 前端(hash 路由 SPA)+ nginx 部署配置与 launchd 托管脚本

如实声明的限制
- 策略缺少稳定的样本外超额收益(Walk-forward 7 窗口均值 -0.95%,
  基准 +2.29%);其价值体现在回撤控制,而非超额收益
- 涨跌停/停牌约束仅覆盖 2019 年起;index_weight 尚未填充
- AI Agent 层(plan.md 第四版 P8)未实现

详见 docs/user-guide.md 与 docs/implementation-status.md。
2026-10-03 13:54:56 +08:00

910 lines
45 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]));
const num = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v))
? '—' : Number(v).toLocaleString('zh-CN', {minimumFractionDigits: d, maximumFractionDigits: d});
const pct = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v))
? '—' : (v * 100).toFixed(d) + '%';
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) {} } }
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 id="c_eq" class="chart"></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>`;
}
async function mountBacktestDetail(runId) {
const eq = await api(`backtests/${runId}/equity`);
if (eq.dates && eq.dates.length) {
chart('c_eq', {
tooltip:{trigger:'axis'}, legend:{top:0,textStyle:{color:'#64748B'}},
grid:{left:66,right: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'}}},
dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:12}],
series:[
{name:'策略净值',type:'line',data:eq.nav,showSymbol:false,lineStyle:{width:2,color:COLORS[0]}},
{name:'基准净值',type:'line',data:eq.bench,showSymbol:false,
lineStyle:{width:1.4,color:COLORS[1],type:'dashed'}},
{name:'回撤',type:'line',data:eq.drawdown,showSymbol:false,yAxisIndex:0,
lineStyle:{width:1,color:COLORS[4]},areaStyle:{opacity:.1}}]
});
}
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="#/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) { /* 未成交信号是可选信息,失败不影响主流程 */ }
}
/* ---------------- 视图:归档 ---------------- */
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 === '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 === 2) {
html = await viewBacktestDetail(parts[1]);
after = () => mountBacktestDetail(parts[1]).catch(e => toast(e.message, true));
}
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 = `#/${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 === '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 === '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';
} catch (e) {
st.textContent = 'API 不可用'; st.className = 'badge fail';
}
document.getElementById('foot-note').textContent =
'数据来自数据库,页面不做任何指标计算 · 删除为软删除,数据不做物理清除';
render();
})();