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

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

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

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

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

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

329 lines
14 KiB
HTML
Raw Permalink 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.
{% extends "base.html" %}
{% block content %}
<div class="kpi-grid">
<div class="kpi">
<div class="label">总收益</div>
<div class="value {{ 'gain' if m.total_return and m.total_return > 0 else ('loss' if m.total_return and m.total_return < 0 else '') }}">{{ m.total_return_s }}</div>
<div class="note">{{ start_date }} ~ {{ end_date }}</div>
</div>
<div class="kpi">
<div class="label">年化 CAGR</div>
<div class="value {{ 'gain' if m.cagr and m.cagr > 0 else ('loss' if m.cagr and m.cagr < 0 else '') }}">{{ m.cagr_s }}</div>
<div class="note">区间 {{ m.years_s }} 年</div>
</div>
<div class="kpi">
<div class="label">最大回撤</div>
<div class="value loss">{{ m.max_drawdown_s }}</div>
<div class="note">{{ dd.peak_date }} → {{ dd.trough_date }}{% if dd.recovery_date %} → 修复 {{ dd.recovery_date }}{% endif %}</div>
</div>
<div class="kpi">
<div class="label">Sharpe</div>
<div class="value {{ 'gain' if m.sharpe and m.sharpe > 0 else 'loss' }}">{{ m.sharpe_s }}</div>
<div class="note">无风险利率 {{ rf }}</div>
</div>
<div class="kpi">
<div class="label">成交笔数</div>
<div class="value">{{ m.trade_count }}</div>
<div class="note">平仓 {{ m.closing_trade_count }} 笔 · 胜率 {{ m.win_rate_s }}</div>
</div>
<div class="kpi">
<div class="label">累计现金分红</div>
<div class="value">{{ dividend_total }}</div>
<div class="note">已扣红利税 {{ dividend_tax }}</div>
</div>
</div>
{% if superseded %}
<div class="callout fail">
<strong>⚠ 这是一份历史运行报告,已被更新的运行取代。</strong>
同策略同模式下还有 {{ superseded.newer_count }} 次更新的回测,最新为
<span class="mono">{{ superseded.latest_run }}</span>({{ superseded.latest_at }})。
旧运行可能产生于成本模型或筛选条件修复之前,<strong>请以最新运行为准</strong>。
本报告保留是为了满足「回测结果必须可复现」的要求,不代表当前结论。
</div>
{% endif %}
{% if unbalanced %}
<div class="callout fail">
<strong>资金对账未通过!</strong>残差 {{ rc.residual }}(容差 1.0 元)。
这通常意味着成本或分红入账存在遗漏,请先修复再解读绩效指标。
</div>
{% else %}
<div class="callout ok">
<strong>资金对账通过</strong> — 恒等式「期初 = 期末现金 + 买入 − 卖出 + 费用 − 分红」
残差仅 {{ rc.residual }} 元(浮点误差量级),说明成本与分红入账完整无遗漏。
</div>
{% endif %}
<div class="card">
<h2>净值曲线与基准对比</h2>
<div id="chart-equity" class="chart"></div>
</div>
<div class="chart-grid">
<div class="card">
<h2>回撤曲线</h2>
<div id="chart-dd" class="chart short"></div>
</div>
<div class="card">
<h2>年度收益</h2>
<div id="chart-year" class="chart short"></div>
</div>
</div>
<div class="card">
<h2>绩效指标(plan.md §28 四类)</h2>
<div class="table-scroll">
<table class="data">
<thead><tr><th style="text-align:left">类别</th><th style="text-align:left">指标</th><th>数值</th><th style="text-align:left">说明</th></tr></thead>
<tbody>
{% for r in metric_rows %}
<tr>
<td style="text-align:left"><span class="badge info">{{ r.category }}</span></td>
<td style="text-align:left">{{ r.label }}</td>
<td class="num"><strong>{{ r.value }}</strong></td>
<td style="text-align:left" class="small muted">{{ r.note }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>基准比较(plan.md §28.4)</h2>
<div class="table-scroll">
<table class="data">
<thead><tr><th style="text-align:left">基准</th><th>总收益</th><th>CAGR</th><th>最大回撤</th><th>年化波动</th><th>超额收益</th></tr></thead>
<tbody>
<tr>
<td style="text-align:left"><strong>策略</strong></td>
<td class="num">{{ m.total_return_s }}</td>
<td class="num">{{ m.cagr_s }}</td>
<td class="num">{{ m.max_drawdown_s }}</td>
<td class="num">{{ m.volatility_s }}</td>
<td class="num">—</td>
</tr>
{% for b in benchmarks %}
<tr>
<td style="text-align:left">{{ b.name }} <span class="mono small muted">{{ b.code }}</span></td>
<td class="num">{{ b.total_return }}</td>
<td class="num">{{ b.cagr }}</td>
<td class="num">{{ b.max_drawdown }}</td>
<td class="num">{{ b.volatility }}</td>
<td class="num {{ 'gain' if b.excess_positive else 'loss' }}">{{ b.excess_return }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if sector_rows %}
<div class="card">
<h2>行业暴露(plan.md §20 集中度)</h2>
<div id="chart-sector" class="chart short"></div>
<div class="table-scroll">
<table class="data">
<thead><tr><th style="text-align:left">行业</th><th>最大权重</th><th>平均权重</th><th>股票数</th></tr></thead>
<tbody>
{% for s in sector_rows %}
<tr><td style="text-align:left">{{ s.industry }}</td><td class="num">{{ s.max_weight }}</td>
<td class="num">{{ s.avg_weight }}</td><td class="num">{{ s.symbols }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="card">
<h2>成交流水(含 reason —— plan.md §32「为什么买」)</h2>
<div class="callout">
每笔交易都记录了触发时的完整理由:股息率取值、历史分位、所用参照窗口、
阈值与规则文本,使 AI 或人工都能事后复核决策依据。
</div>
<div class="table-scroll">
<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 style="text-align:left">触发理由</th></tr>
</thead>
<tbody>
{% for t in trade_rows %}
<tr>
<td class="num">{{ loop.index }}</td>
<td class="mono">{{ t.symbol }}</td>
<td class="num">{{ t.signal_date }}</td>
<td class="num">{{ t.execution_date }}</td>
<td><span class="badge {{ 'BUY' if t.side == 'BUY' else 'SELL' }}">{{ t.side }}</span></td>
<td class="num">{{ t.price }}</td>
<td class="num">{{ t.quantity }}</td>
<td class="num">{{ t.amount }}</td>
<td class="num">{{ t.fees }}</td>
<td class="num {{ 'gain' if t.pnl_positive else ('loss' if t.pnl_negative else '') }}">{{ t.pnl }}</td>
<td class="num">{{ t.holding_days }}</td>
<td style="text-align:left" class="small">{{ t.reason }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if signal_skips %}
<div class="card">
<h2>未成交信号与原因</h2>
<div class="callout warn">
这些信号「想交易但没成交」。区分它们与已成交记录,才能判断策略表现
是否被涨跌停/停牌等约束实质影响。
</div>
<div class="table-scroll">
<table class="data">
<thead><tr><th>代码</th><th>信号日</th><th>类型</th><th>分位</th><th style="text-align:left">未成交原因</th></tr></thead>
<tbody>
{% for s in signal_skips %}
<tr><td class="mono">{{ s.symbol }}</td><td class="num">{{ s.signal_date }}</td>
<td><span class="badge info">{{ s.kind }}</span></td><td class="num">{{ s.pct }}</td>
<td style="text-align:left" class="small">{{ s.reason }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if unimplemented %}
<div class="callout warn">
<strong>未建模部分(如实声明,不假装已实现):</strong>
<ul style="margin:4px 0 0 18px">
{% for u in unimplemented %}<li>{{ u }}</li>{% endfor %}
</ul>
</div>
{% endif %}
<div class="card">
<h2>回测配置与可复现性(plan.md §47 原则 6)</h2>
<div class="table-scroll">
<table class="data">
<thead><tr><th style="text-align:left">项</th><th style="text-align:left">值</th></tr></thead>
<tbody>
<tr><td style="text-align:left">策略</td><td style="text-align:left" class="mono">{{ strategy_id }} v{{ strategy_version }}</td></tr>
<tr><td style="text-align:left">策略指纹 config_hash</td><td style="text-align:left" class="mono">{{ config_hash }}</td></tr>
<tr><td style="text-align:left">数据版本 data_version</td><td style="text-align:left" class="mono">{{ data_version }}</td></tr>
<tr><td style="text-align:left">代码版本 code_version</td><td style="text-align:left" class="mono">{{ code_version }}</td></tr>
<tr><td style="text-align:left">随机种子 seed</td><td style="text-align:left" class="mono">{{ seed }}</td></tr>
<tr><td style="text-align:left">信号评估频率</td><td style="text-align:left">{{ signal_freq }}</td></tr>
<tr><td style="text-align:left">分位参照口径</td><td style="text-align:left">{{ reference_mode }}</td></tr>
<tr><td style="text-align:left">成交价</td><td style="text-align:left">{{ fill_price }}</td></tr>
</tbody>
</table>
</div>
<details>
<summary>查看生效的策略配置</summary>
<pre class="mono small" style="white-space:pre-wrap;max-height:420px;overflow:auto">{{ strategy_json }}</pre>
</details>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
if (typeof echarts === 'undefined') return;
var C = {{ chart_colors | json }};
var eq = document.getElementById('chart-equity');
if (eq) {
var d = {{ equity_chart | json }};
var c = echarts.init(eq);
c.setOption({
color: C,
grid: { left: 66, right: 30, top: 40, bottom: 60 },
tooltip: { trigger: 'axis' },
legend: { top: 0, textStyle: { color: '#64748B' } },
xAxis: { type: 'category', data: d.dates, axisLabel: { color: '#64748B',
formatter: function (v) { return String(v).slice(0, 7); } } },
yAxis: { type: 'value', scale: true, name: '净值', axisLabel: { color: '#64748B' },
splitLine: { lineStyle: { color: '#E9EEF6' } } },
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 18, bottom: 12 }],
series: [
{ name: '策略净值', type: 'line', data: d.nav, showSymbol: false,
lineStyle: { width: 2, color: C[0] } },
{ name: '基准净值', type: 'line', data: d.bench, showSymbol: false,
lineStyle: { width: 1.4, color: C[1], type: 'dashed' } }
]
});
window.addEventListener('resize', function () { c.resize(); });
}
var dd = document.getElementById('chart-dd');
if (dd) {
var d2 = {{ equity_chart | json }};
var c2 = echarts.init(dd);
c2.setOption({
color: C,
grid: { left: 66, right: 24, top: 24, bottom: 44 },
tooltip: { trigger: 'axis',
valueFormatter: function (v) { return (v * 100).toFixed(2) + '%'; } },
xAxis: { type: 'category', data: d2.dates, axisLabel: { color: '#64748B',
formatter: function (v) { return String(v).slice(0, 7); } } },
yAxis: { type: 'value', name: '回撤', axisLabel: { color: '#64748B',
formatter: function (v) { return (v * 100).toFixed(0) + '%'; } },
splitLine: { lineStyle: { color: '#E9EEF6' } } },
series: [{ name: '回撤', type: 'line', data: d2.dd, showSymbol: false,
areaStyle: { opacity: 0.18 }, lineStyle: { width: 1.2, color: C[4] } }]
});
window.addEventListener('resize', function () { c2.resize(); });
}
var yr = document.getElementById('chart-year');
if (yr) {
var y = {{ year_chart | json }};
var c3 = echarts.init(yr);
c3.setOption({
color: C,
grid: { left: 62, right: 24, top: 24, bottom: 44 },
tooltip: { trigger: 'axis',
valueFormatter: function (v) { return (v * 100).toFixed(2) + '%'; } },
xAxis: { type: 'category', data: y.years, axisLabel: { color: '#64748B' } },
yAxis: { type: 'value', axisLabel: { color: '#64748B',
formatter: function (v) { return (v * 100).toFixed(0) + '%'; } },
splitLine: { lineStyle: { color: '#E9EEF6' } } },
series: [{ type: 'bar', data: y.values.map(function (v) {
return { value: v, itemStyle: { color: v >= 0 ? '#DC2626' : '#059669' } };
}), barMaxWidth: 46,
label: { show: true, position: 'top', fontSize: 10, color: '#0F172A',
formatter: function (p) { return (p.value * 100).toFixed(1) + '%'; } } }]
});
window.addEventListener('resize', function () { c3.resize(); });
}
var sec = document.getElementById('chart-sector');
if (sec) {
var s = {{ sector_chart | json }};
var c4 = echarts.init(sec);
c4.setOption({
color: C,
grid: { left: 110, right: 40, top: 20, bottom: 30 },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' },
valueFormatter: function (v) { return (v * 100).toFixed(2) + '%'; } },
xAxis: { type: 'value', axisLabel: { color: '#64748B',
formatter: function (v) { return (v * 100).toFixed(0) + '%'; } },
splitLine: { lineStyle: { color: '#E9EEF6' } } },
yAxis: { type: 'category', data: s.names, axisLabel: { color: '#64748B' } },
series: [{ type: 'bar', data: s.values, itemStyle: { color: C[0] },
barMaxWidth: 22,
markLine: s.limit == null ? undefined : { silent: true, symbol: 'none',
data: [{ xAxis: s.limit, lineStyle: { color: C[4], type: 'dashed' },
label: { formatter: '行业上限', color: C[4] } }] } }]
});
window.addEventListener('resize', function () { c4.resize(); });
}
})();
</script>
{% endblock %}