Files
ggx/web/app.js
T
simon cf6d4d2c56 功能:每日动态股票池回测(--mode daily)+ 每日增量同步 + PIT 批量取数层
说明:本提交是工作区中此前的未提交工作(在 14ec0c6 之后产生),**非本次会话所写**,
按用户要求整理并推送。已做安全检查(无明文凭据、无大文件、.env/logs/output 仍被忽略),
并完成可执行范围内的测试验证(见「测试」一节)。

## 新增能力

1) `hdiv backtest --mode daily --start <日期>`
   - src/hdiv/backtest/daily.py:两趟式(先逐日选股,再复用既有引擎模拟)
   - 每个交易日按当日可见数据重建股票池(PIT),每个交易日判断买卖点
   - `pool_exit_action`:hold(只减不加、不因掉出池子而清仓)/ sell(掉出即清仓)
   - `profile_on_trade`:买卖决策发生时计算并留痕个股画像,**不区分是否在当日池内**
     (卖出/减仓同样留痕,否则「为什么卖」缺证据)
   - 与 walkforward 的分工:daily 是一条连续路径的推演,不是过拟合检验;
     因此不使用训练段、不冻结分布,阈值口径一律 rolling
   - 拒绝 `--universe-run`(daily 的定义就是逐日重筛,冻结池与之矛盾)

2) PIT 批量取数层 src/hdiv/universe/pit.py
   - PitRepo 继承 Repo,**只重写取数**(按区块批量预载 + 逐日内存切片),
     派生逻辑(最新一期财报合并、单位归一化、支付率口径等)一行不重写
     —— 以保证与逐日单点查询**结果等价**
   - 候选集预剪枝:用「不可能通过」的边界条件提前排除,文档论证为精确等价而非近似
   - src/hdiv/universe/daily.py:每日动态筛选器(仍然调用既有 selector 与四个 Filter)

3) 每日增量同步 `hdiv sync daily`
   - src/hdiv/data/sync/daily.py:只抓「库里还没有的那几天」,
     按「当日股票数 ≥ 当年规模阈值」判定缺口,不重拉历史、不覆盖既有行;
     支持 `--dry-run` 先看待抓清单
   - deploy/daily-sync.sh、deploy/install-sync-schedule.sh、
     deploy/com.hddiv.sync.plist.example(launchd 每天 17:00)
   - 新表 hd_daily_universe(逐日入选成员留痕)+ sql/hd_daily_universe.sql + schema.py
     (该表已存在于库中,`ddl plan` 返回 0 个待执行动作)

4) Web 与文档
   - 前端支持 daily 模式记录下钻(web/app.js、web/app.css、web/index.html、
     web/favicon.svg)
   - README / docs/user-guide.md / docs/implementation-status.md 同步更新:
     三种回测模式的取舍、daily 的成本说明(6.7 年约 1.5 小时)与调优手段

## 测试

tests/ 共 500 项(新增 tests/test_daily.py 43 项、tests/test_sync_daily.py 36 项)。

已验证通过:
- 排除上述两个新文件的 **421 项:全部通过(pytest 退出码 0)**
- 两个新文件的**非 DB 单元测试 60 项:全部通过**

未能在合理时间内跑完:
- 两个新文件中 **19 项 DB 标记的重型测试**。实测瓶颈是一条**无界全表扫描**:
  `SELECT ... FROM hd_cashflow WHERE ann_date <= :asof ORDER BY symbol, end_date, ann_date`
  (31 万行,无 symbol/报告期下限)。全量套件跑到 161 项时已耗时 20 分钟、
  0 失败,按该速率预计需 3 小时以上,因此改为分档验证。
- 旁证:库中存在 3 次成功的 daily 端到端运行(2026-10-05 10:05 / 10:32 / 11:03,
  区间 2024-03-01~03-15),说明该路径可正常完成。

## 已知待改进

- 上述 `hd_cashflow`(及同类「按 ann_date 上界取全历史」)的查询缺
  symbol / 报告期下限,是 daily 模式的主要性能瓶颈,建议下一轮优化。
2026-10-05 11:57:13 +08:00

2085 lines
104 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 class="toolbar">
<span class="small muted">区间</span>
<input type="date" id="sp-from" style="width:auto">
<span class="small muted">~</span>
<input type="date" id="sp-to" style="width:auto">
<button data-act="sp-apply">应用</button>
<button data-act="sp-reset">重置</button>
<span class="small muted" id="sp-hint"></span>
</div>
<div id="c_main" class="chart tall"></div>
<div class="callout" style="margin-top:12px">
区间只影响这张走势图;右侧分布统计与雷达图仍是<b>画像当时的全历史</b>口径。
</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>`;
}
const spState = {data: null, from: '', to: ''};
function mountStock(d) {
const ch = d.chart || {};
if (!ch.dates || !ch.dates.length) return;
spState.data = d;
spState.from = ''; spState.to = ''; // 换股票回到全区间
const from = document.getElementById('sp-from');
const to = document.getElementById('sp-to');
if (from && to) {
// 画像序列是快照,边界就是能选的全部;不设上限会让人以为能选到今天
from.min = to.min = ch.dates[0];
from.max = to.max = ch.dates[ch.dates.length - 1];
from.value = ch.dates[0];
to.value = ch.dates[ch.dates.length - 1];
}
paintStockMain();
// 直方图(用序列值前端分箱,属于纯呈现,不是业务计算)
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]}}]}]
});
}
}
}
/** 画画像走势图;区间是纯前端截取(画像序列已全量在手,不必再取数)。 */
function paintStockMain() {
const d = spState.data;
const ch = d && d.chart;
if (!ch || !ch.dates || !ch.dates.length) return;
const all = ch.dates;
let i0 = 0, i1 = all.length - 1;
if (spState.from) { const k = all.findIndex(x => x >= spState.from); if (k >= 0) i0 = k; }
if (spState.to) {
for (let i = all.length - 1; i >= 0; i--) { if (all[i] <= spState.to) { i1 = i; break; } }
}
if (i1 < i0) { toast('开始日期不能晚于结束日期', true); return; }
const dates = all.slice(i0, i1 + 1);
const cut = k => ((ch[k] || []).length === all.length ? ch[k].slice(i0, i1 + 1) : []);
const hint = document.getElementById('sp-hint');
if (hint) {
const asof = (d.profile_run && d.profile_run.asof_date) || all[all.length - 1];
hint.textContent = `${dates[0]} ~ ${dates[dates.length - 1]} · ${dates.length} 个交易日`
+ ` · 画像快照止于 ${asof}`;
}
disposeChart('c_main');
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:dates,gridIndex:0,axisLabel:{show:false},axisTick:{show:false}},
{type:'category',data:dates,gridIndex:1,axisLabel:{show:false},axisTick:{show:false}},
{type:'category',data: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:cut('close'),showSymbol:false,
lineStyle:{width:1.4,color:COLORS[0]},itemStyle:{color:COLORS[0]}},
{name:'股息率',type:'line',xAxisIndex:1,yAxisIndex:1,data:cut('dv_yield'),showSymbol:false,
lineStyle:{width:1.6,color:COLORS[1]},itemStyle:{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:cut('pe_ttm'),showSymbol:false,
lineStyle:{width:1.2,color:COLORS[2]},itemStyle:{color:COLORS[2]}}]
});
}
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>`}`;
}
/* ---------------- 视图:回测详情 ---------------- */
/** 个股画像闸门:买入信号触发后,用当日可见数据重算画像再逐条核验。 */
function profileGateCard(g) {
const rules = (g && g.rules) || [];
if (!rules.length) return ''; // 该回测没配画像闸门 → 不占版面
const STAT = {current_value: '当日值',
current_percentile: '当日值在窗口分布中的分位'};
const OP = {'>=': '≥', '<=': '≤', '>': '>', '<': '<', '==': '='};
const fmtVal = (metric, v) => {
if (v == null) return '—';
const u = UNIT[metric] || 'ratio';
if (u === 'pct') return pct(v, 2);
if (u === 'int') return num(v, 0) + (metric.endsWith('_years') ? ' 年' : '');
if (u === 'price') return num(v, 2) + ' 元';
return num(v, 2); // ratio:倍数/覆盖
};
const meta = [
`画像窗口 ${num(g.window_years, 0)} 年`,
g.on_unverifiable === 'pass'
? '数据缺失/样本不足时:放行'
: '数据缺失/样本不足时:保守不买(REJECT)',
g.min_window_coverage ? `最小窗口覆盖率 ${pct(g.min_window_coverage, 0)}` : null,
].filter(Boolean).join(' · ');
return `
<div class="card">
<h2>个股画像筛选条件
<span class="badge ${g.enabled ? 'info' : 'dim'}">${g.enabled ? '已启用' : '未启用'}</span></h2>
<div class="callout">
这是<b>买入的第二道闸门</b>:股息率分位触发买入之后,用<b>当日可见的数据</b>
重算一次个股画像,再逐条核验,不通过的票直接剔除
(信号类型 <code class="mono">REJECT</code>,每条规则当时的实际值与阈值可在下方
「未成交信号与原因」里看到)。<b>取不到值不等于通过</b>,按下方「数据缺失」口径处理。
</div>
<div class="toolbar"><span class="small muted">${esc(meta)}</span></div>
<div class="tw"><table class="data">
<thead><tr><th class="l">#</th><th class="l">画像指标</th>
<th class="l">核验口径</th><th class="l">必须满足</th></tr></thead>
<tbody>${rules.map((r, i) => `<tr>
<td class="num">${i + 1}</td>
<td class="l">${esc(LABEL[r.metric] || r.metric)}</td>
<td class="l small">${esc(STAT[r.stat] || r.stat || '—')}</td>
<td class="l mono">${esc(OP[r.op] || r.op || '')} ${esc(fmtVal(r.metric, r.value))}</td>
</tr>`).join('')}</tbody>
</table></div>
</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" id="closed-card">
<h2>已清仓了结 <span class="badge info" id="closed-count">…</span></h2>
<div class="callout">
期末<b>不再持有</b>的个股(已全部卖出)。<b>卖出后至今</b>是清仓日收盘到最新收盘的涨跌:
<b class="loss">跌</b>说明这笔卖对了,<b class="gain">涨</b>说明可能卖早了。
<b>收益率</b> = 已实现盈亏 ÷ 买入金额,即这笔投资的累计收益率(不是年化;
已清仓,所以全部盈亏都已落袋)。
点代码可打开该股在本次回测中的走势图(默认已经画到最新行情)。
</div>
<div id="closed-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>
${profileGateCard(b.strategy.profile_gate)}
<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="du-card" style="display:none">
<h2>每日动态股票池 <span class="badge info" id="du-badge">…</span></h2>
<div class="callout">
本模式<b>每个交易日</b>按当时可见数据重建股票池(PIT)。
每个决策日的入选成员都留了痕,可回答「某天为什么是这些股票」。
</div>
<div class="toolbar">
<span class="small muted">决策日</span>
<select id="du-date" style="width:auto"></select>
<span class="small muted" id="du-note"></span>
</div>
<div id="du-body"><div class="loading">加载中…</div></div>
</div>
<div class="card" id="tradeprof-card" style="display:none">
<h2>成交个股的实时画像 <span class="badge info" id="tradeprof-badge">…</span></h2>
<div class="callout">
每一笔成交,都附上<b>该笔决策当天、只用当时可见数据</b>算出的个股画像(PIT)。
这与「个股画像」页的批量画像同一定义,只是按决策日取值 ——
它回答的是「当时凭什么买/卖」。点代码可进入个股页看完整曲线与全部画像指标。
</div>
<div id="tradeprof"><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 + ' 笔';
// 成交个股的实时画像(仅 daily 模式有留痕;无留痕时整张卡片不显示)
renderTradedProfiles(runId, t.items);
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 {
renderClosedPositions(runId, await api(`backtests/${runId}/closed-positions`));
} catch (e) {
const host = document.getElementById('closed-body');
if (host) host.innerHTML = `<div class="callout fail">加载失败:${esc(e.message)}</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) { /* 未成交信号是可选信息,失败不影响主流程 */ }
try { await mountDailyUniverse(runId); }
catch (e) { /* 每日动态股票池只在 --mode daily 存在,失败不影响主流程 */ }
}
/* ---------------- 回测详情:成交个股的实时画像(--mode daily) ---------------- */
/** 把每笔成交的 `reason.profile` 汇总成一张表。
*
* 数据源:`GET /api/backtests/{id}/trades` 的 `items[].reason.profile`
* (由引擎在决策当日写入,不是事后重算)。非 daily 模式或关闭了
* `daily.profile_on_trade` 时没有任何留痕,整张卡片不显示。
*/
/** 已清仓了结清单。清仓复盘真正要回答的是「这笔卖对了没有」。 */
function renderClosedPositions(runId, cp) {
const host = document.getElementById('closed-body');
const badge = document.getElementById('closed-count');
if (!host) return;
const s = cp.summary || {};
if (badge) badge.textContent = `${num(s.count, 0)} 只`;
if (!cp.items || !cp.items.length) {
host.innerHTML = '<div class="empty">本次回测没有已清仓的个股'
+ '(期末仍持有的都在上方「持仓明细」里)</div>';
return;
}
host.innerHTML = `
<div class="toolbar">
<span class="small muted">共 <b>${num(s.count, 0)}</b> 只 · 合计已实现
<b class="${sign(s.realized_pnl)}">${yi(s.realized_pnl)}</b> ·
清仓后<b class="gain">上涨 ${num(s.since_sell_up, 0)}</b> /
<b class="loss">下跌 ${num(s.since_sell_down, 0)}</b> 只
${s.asof ? `· 价格截至 <span class="mono">${esc(s.asof)}</span>` : ''}</span>
</div>
<div class="tw"><table class="data">
<thead><tr><th class="l">代码</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 class="l">清仓理由</th></tr></thead>
<tbody>${cp.items.map(x => `<tr>
<td class="l"><a href="#/backtests/${esc(runId)}/stocks/${esc(x.symbol)}"
class="mono">${esc(x.symbol)}</a></td>
<td class="l">${esc(x.name || '—')}</td>
<td class="l small">${esc(x.industry || '—')}</td>
<td class="l num small">${esc(x.first_hold || '—')} ~ ${esc(x.last_hold || '—')}</td>
<td class="num">${num(x.hold_days, 0)}</td>
<td class="num">${num(x.buy_amount, 0)}</td>
<td class="num">${num(x.sell_amount, 0)}</td>
<td class="num ${sign(x.realized_pnl)}">${num(x.realized_pnl, 0)}</td>
<td class="num ${sign(x.return_pct)}">${pct(x.return_pct)}</td>
<td class="num">${esc(x.last_sell || '—')}</td>
<td class="num ${sign(x.since_sell_pct)}">${pct(x.since_sell_pct)}</td>
<td class="l small reason" title="${esc(x.sell_reason || '')}">${esc(x.sell_reason || '—')}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
function renderTradedProfiles(runId, items) {
const card = document.getElementById('tradeprof-card');
const host = document.getElementById('tradeprof');
if (!card || !host) return;
const rows = (items || []).filter(t => t.reason && t.reason.profile);
if (!rows.length) return; // 无留痕 → 不显示该卡片
card.style.display = '';
const syms = new Set(rows.map(t => t.symbol));
const badge = document.getElementById('tradeprof-badge');
if (badge) badge.textContent = `${syms.size} 只 / ${rows.length} 笔成交`;
const numOr = (v, u) => (v === null || v === undefined ? '—' : fmtUnit(v, u));
host.innerHTML = `<div class="tw"><table class="data">
<thead><tr><th class="l">代码</th><th>信号日</th><th>成交日</th><th>方向</th>
<th>股息率</th><th>股息率分位</th><th>PE(TTM)</th><th>PB</th>
<th>ROE(5年均)</th><th>连续分红</th><th>支付率</th><th>FCF覆盖</th>
<th>当日池内</th></tr></thead>
<tbody>${rows.map(t => {
const p = t.reason.profile || {};
const v = p.values || {};
const pc = p.percentiles || {};
const inPool = t.reason.in_universe;
return `<tr>
<td class="l"><a class="mono"
href="#/backtests/${esc(runId)}/stocks/${esc(t.symbol)}">${esc(t.symbol)}</a></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">${numOr(v.dv_yield, 'pct')}</td>
<td class="num">${pc.dv_yield == null ? '—' : num(pc.dv_yield, 1) + '%'}</td>
<td class="num">${numOr(v.pe_ttm, 'ratio')}</td>
<td class="num">${numOr(v.pb, 'ratio')}</td>
<td class="num">${numOr(v.roe_avg, 'pct')}</td>
<td class="num">${numOr(v.dividend_continuity_years, 'int')} 年</td>
<td class="num">${numOr(v.payout_ratio, 'pct')}</td>
<td class="num">${v.fcf_dividend_cover == null ? '—'
: num(v.fcf_dividend_cover, 2) + 'x'}</td>
<td>${inPool === undefined || inPool === null ? '—'
: (inPool ? '<span class="badge ok">在</span>'
: '<span class="badge warn">已出</span>')}</td></tr>`;
}).join('')}</tbody></table></div>
<div class="callout" style="margin-top:12px">
「当日池内 = 已出」表示该笔发生在持仓<b>掉出当日股票池之后</b>
(<code>pool_exit_action: hold</code> 下只减不加 / 按分位卖出)。
每行的数值都取自该笔成交的 <code>reason_json.profile</code>,可用 SQL 逐条复核。
</div>`;
}
/* ---------------- 回测详情:每日动态股票池(--mode daily) ---------------- */
const duState = {runId: null, timeline: [], date: null};
async function mountDailyUniverse(runId) {
const card = document.getElementById('du-card');
if (!card) return;
const tl = await api(`backtests/${runId}/daily-universe`);
if (!tl.available || !tl.timeline || !tl.timeline.length) return; // 非 daily 模式
duState.runId = runId;
duState.timeline = tl.timeline;
card.style.display = '';
const sizes = tl.timeline.map(x => x.member_count);
const uniq = new Set();
document.getElementById('du-badge').textContent =
`${tl.timeline.length} 个有成员的决策日 · 池内 ${Math.min(...sizes)}~${Math.max(...sizes)} 只`;
const sel = document.getElementById('du-date');
sel.innerHTML = tl.timeline.map(x =>
`<option value="${esc(x.trade_date)}"${x.trade_date === tl.trade_date ? ' selected' : ''}>`
+ `${esc(x.trade_date)}(${x.member_count} 只)</option>`).join('');
sel.addEventListener('change', () => loadDailyUniverse(sel.value));
duState.date = tl.trade_date;
await loadDailyUniverse(duState.date);
}
async function loadDailyUniverse(date) {
const host = document.getElementById('du-body');
const note = document.getElementById('du-note');
if (!host) return;
host.innerHTML = '<div class="loading">加载中…</div>';
const d = await api(`backtests/${duState.runId}/daily-universe?date=${encodeURIComponent(date)}`);
const items = d.members || [];
if (note) {
const row = duState.timeline.find(x => x.trade_date === date) || {};
note.textContent = row.listed_count
? `市场候选 ${row.listed_count} 只 → 实际筛选 ${row.candidate_count ?? '—'} 只 → 入选 ${items.length} 只`
: '';
}
if (!items.length) { host.innerHTML = '<div class="empty">该日无入选成员</div>'; return; }
host.innerHTML = `<div class="tw"><table class="data">
<thead><tr><th class="l">代码</th><th class="l">名称</th><th class="l">行业</th>
<th>股息率</th><th>PE(TTM)</th><th>PB</th><th>总市值</th>
<th>5年ROE</th><th>连续分红</th><th>支付率</th><th>FCF覆盖</th></tr></thead>
<tbody>${items.map(x => {
const v = x.values || {};
return `<tr>
<td class="l mono">${esc(x.symbol)}</td>
<td class="l">${esc(x.name || '—')}</td>
<td class="l small">${esc(x.industry || '—')}</td>
<td class="num">${pct(x.dividend_yield)}</td>
<td class="num">${num(v.pe_ttm)}</td>
<td class="num">${num(v.pb)}</td>
<td class="num">${yi(x.total_mv)}</td>
<td class="num">${pct(x.roe_avg)}</td>
<td class="num">${num(v.dividend_continuity_years,0)} 年</td>
<td class="num">${pct(v.payout_ratio)}</td>
<td class="num">${num(v.fcf_dividend_cover)}x</td></tr>`;
}).join('')}
</tbody></table></div>
<div class="callout" style="margin-top:12px">
「股息率」是<b>筛选口径</b>(<code>universe.yml: yield_source</code>,默认自算 TTM);
PE/PB 来自 <code>daily_basic</code>;5 年 ROE、支付率、FCF 覆盖来自年报(PIT:
只用当时已公告的财报)。
</div>`;
}
/* ---------------- 回测详情:持仓明细 ---------------- */
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: {},
from: '', to: '', seq: 0, rtDone: false};
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>
<input type="date" id="st-from" style="width:auto">
<span class="small muted">~</span>
<input type="date" id="st-to" style="width:auto">
<button data-act="st-apply">应用</button>
<button data-act="st-reset">重置</button>
<span class="small muted" id="st-range-hint"></span>
</div>
<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('')}
</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>上,
位置取该指标在成交日的取值;鼠标悬停可一次看全部指标与成交的价格、股数、金额。<br/>
默认区间 = 首笔成交<b>往前 5 年</b>(股息率分位的判据窗口)→ <b>该股最新行情</b>:
往前留够判据,才能回答「当时凭什么买」;一直画到今天,
才能回答「卖飞了还是卖对了」。区间可自由改。
股息率为 PIT-TTM 口径(TTM 每股分红 ÷ 不复权收盘价),
ROE 按<b>公告日</b>对齐成阶梯线(不插值,避免未来函数)。
</div>
</div>
<div class="card" id="rt-profile-card">
<h2>决策时点实时画像 <span class="badge info" id="rt-badge">…</span></h2>
<div class="callout">
这是<b>每个买卖决策当天、只用当时可见数据</b>算出的个股画像(PIT)。
与「个股画像」页的批量画像同一定义,但按<b>决策日</b>取值 ——
它回答的是「当时凭什么买/卖」,而不是「今天回头看它长什么样」。
数值来自成交记录的 <code>reason_json.profile</code>,可用 SQL 逐条复核。
</div>
<div id="rt-profile"></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>`;
}
/** 决策时点实时画像:把成交记录里的 reason_json.profile 渲染成表。
*
* 数据来自 `GET /api/backtests/{id}/stocks/{symbol}` 的 trades[].reason.profile,
* 即引擎在决策当日算出的 PIT 画像快照 —— 不是事后重算。
*/
function renderRtProfile(trades) {
const rows = (trades || []).filter(t => t.reason && t.reason.profile);
const host = document.getElementById('rt-profile');
const badge = document.getElementById('rt-badge');
if (!host) return;
if (badge) badge.textContent = rows.length ? rows.length + ' 个决策时点' : '无留痕';
if (!rows.length) {
host.innerHTML = '<div class="empty">本次回测的成交记录里没有画像留痕'
+ '(仅 <code>--mode daily</code> 且 <code>daily.profile_on_trade: true</code> 时记录)</div>';
return;
}
// 优先展示的判据指标(与闸门规则同一批),其余放在展开区
const PRIMARY = ['dv_yield', 'dv_yield_pct', 'pe_ttm', 'pb', 'roe_avg',
'dividend_continuity_years', 'payout_ratio', 'fcf_dividend_cover'];
host.innerHTML = rows.map(t => {
const p = t.reason.profile || {};
const vals = p.values || {};
const pcts = p.percentiles || {};
const cells = PRIMARY.map(k => {
if (k === 'dv_yield_pct') {
return `<td class="num">${pcts.dv_yield == null ? '—' : num(pcts.dv_yield, 1) + '%'}</td>`;
}
const u = UNIT[k] || 'ratio';
return `<td class="num">${fmtUnit(vals[k], u)}</td>`;
}).join('');
// 其余指标(全历史/各窗口的完整快照)折叠展示,避免默认刷屏
const others = Object.keys(vals).filter(k => !PRIMARY.includes(k) && k !== 'dv_yield_pct')
.sort().map(k => `<span class="mono small" style="margin-right:10px">`
+ `${esc(LABEL[k] || k)}=${fmtUnit(vals[k], UNIT[k] || 'ratio')}</span>`).join('');
const gate = t.reason.profile_gate;
const gateTxt = gate
? `<span class="badge ${gate.verdict === 'PASS' ? 'ok' : 'warn'}">闸门 ${esc(gate.verdict)}</span>`
: '';
return `<div style="border-top:1px solid var(--line);padding:10px 0">
<div class="small" style="margin-bottom:6px">
<b>${esc(t.signal_date)}</b>
<span class="badge ${t.side === 'BUY' ? 'gain' : 'loss'}">${t.side === 'BUY' ? '买入' : '卖出'}</span>
${gateTxt}
<span class="muted">画像窗口 ${esc(String(p.window_years ?? '—'))} 年 · 决策日 ${esc(p.asof || '—')}</span>
</div>
<div class="tw"><table class="data"><thead><tr>
${PRIMARY.map(k => `<th>${esc(k === 'dv_yield_pct' ? '股息率分位'
: (LABEL[k] || k))}</th>`).join('')}
</tr></thead><tbody><tr>${cells}</tr></tbody></table></div>
${others ? `<details style="margin-top:6px"><summary class="small muted">
展开该决策日的全部 ${Object.keys(vals).length} 项画像指标</summary>
<div style="margin-top:6px">${others}</div></details>` : ''}
</div>`;
}).join('');
}
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 range = (stState.from ? `&start=${encodeURIComponent(stState.from)}` : '')
+ (stState.to ? `&end=${encodeURIComponent(stState.to)}` : '');
const seq = ++stState.seq;
const d = await api(`backtests/${runId}/stocks/${encodeURIComponent(symbol)}`
+ `?series=${shown.join(',')}${range}`);
if (seq !== stState.seq) return; // 连续改区间时丢掉过期响应
stState.data = d;
// 决策时点实时画像与 K 线无关(不随 series/区间变化),只在本次挂载渲染一次
if (!stState.rtDone) { renderRtProfile(d.trades); stState.rtDone = true; }
host.innerHTML = '';
syncStockRange(d.range);
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;
}
/** 把实际生效的区间写回日期框,并把该股行情边界设成 min/max。 */
function syncStockRange(r) {
const from = document.getElementById('st-from');
const to = document.getElementById('st-to');
if (from && to) {
if (r.available_start) { from.min = r.available_start; to.min = r.available_start; }
if (r.available_end) { from.max = r.available_end; to.max = r.available_end; }
from.value = r.start || '';
to.value = r.end || '';
}
const hint = document.getElementById('st-range-hint');
if (hint) {
hint.textContent = `${r.start} ~ ${r.end} · ${r.points} 个交易日`
+ (r.downsampled ? '(已降采样显示)' : '')
+ (r.available_end ? ` · 该股行情至 ${r.available_end}` : '');
}
}
/** 把选中的指标画成 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);
});
// 区间外的成交不占横轴类目(用户把区间收窄时,把 2021 年的买卖点塞进
// 2024 年的横轴会把图彻底搞乱),只用下方文字提示它们的存在。
const outside = d.trades.filter(t => !dateIndex.has(t.execution_date));
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: d.dates, 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,
data: vals, 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 => {
const j = dateIndex.get(t.execution_date);
if (j === undefined) return null; // 区间外:见下方文字提示
if (k === 'close') return t.price == null ? null : [t.execution_date, t.price];
const v = 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 = outside.map(t =>
`${t.execution_date} ${t.side === 'BUY' ? '买入' : '卖出'} ${num(t.price, 3)} 元`);
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/>
把区间放宽(或点「重置」)即可看到这些买卖点。
</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>`;
}
/* ---------------- 路由 ---------------- */
// 「已渲染路径」缓存。哨兵值必须是 null,**不能是空串**:首页(hash 为空)解析出的
// path 正是 '',若用 '' 表示「还没渲染」,render() 会在首页一开始就
// `path === currentPath` 提前返回 —— 概览页永远停在 index.html 里那句「加载中…」。
// 同理,所有「强制重渲染」的地方一律写 currentPath = null。
let currentPath = null;
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];
stState.from = ''; stState.to = ''; // 换股票回到默认区间
stState.rtDone = false; // 换股票要重渲染画像留痕
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=null; render(); }
else { currentPath=null; render(); }
});
return;
}
if (act === 'archive') {
await patch(`${base}/${id}`, {archived: btn.dataset.to === '1'});
toast(btn.dataset.to === '1' ? '已归档' : '已取消归档');
currentPath = null; 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 = null; render(); return;
}
if (act === 'pf-shift') {
shiftPortfolio(parseInt(btn.dataset.d, 10));
return;
}
if (act === 'st-apply' || act === 'st-reset') {
const host = document.getElementById('c_stock');
if (!host) return;
const shown = [...document.querySelectorAll('.st-ser:checked')].map(x => x.value);
if (!shown.length) { toast('请至少勾选一个指标', true); return; }
if (act === 'st-reset') {
stState.from = ''; stState.to = '';
} else {
const from = (document.getElementById('st-from') || {}).value || '';
const to = (document.getElementById('st-to') || {}).value || '';
if (from && to && from > to) {
toast('开始日期不能晚于结束日期', true); return;
}
stState.from = from; stState.to = to;
}
disposeCharts();
const {parts} = parseHash();
mountBacktestStock(parts[1], parts[3]);
return;
}
if (act === 'sp-apply' || act === 'sp-reset') {
if (!spState.data) return;
if (act === 'sp-reset') {
spState.from = ''; spState.to = '';
const ch = (spState.data.chart || {});
const from = document.getElementById('sp-from');
const to = document.getElementById('sp-to');
if (from && ch.dates) from.value = ch.dates[0];
if (to && ch.dates) to.value = ch.dates[ch.dates.length - 1];
} else {
const from = (document.getElementById('sp-from') || {}).value || '';
const to = (document.getElementById('sp-to') || {}).value || '';
if (from && to && from > to) { toast('开始日期不能晚于结束日期', true); return; }
spState.from = from; spState.to = to;
}
paintStockMain();
return;
}
if (act === 'page') {
const {parts, q} = parseHash();
q.set('page', btn.dataset.p);
location.hash = `#/${parts.join('/')}?${q.toString()}`;
currentPath = null; 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 = null; 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 = null; 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 = null; 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();
})();