初始提交:高股息策略研究与回测系统
从 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。
This commit is contained in:
+909
@@ -0,0 +1,909 @@
|
||||
/* ============================================================
|
||||
高股息回测系统 · 统一前端
|
||||
前后端分离:本文件只通过 /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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|
||||
})();
|
||||
Reference in New Issue
Block a user