初始提交:高股息策略研究与回测系统

从 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:
2026-10-03 13:54:56 +08:00
commit fce725e13c
127 changed files with 28001 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
{% extends "base.html" %}
{% block content %}
{% if analysis %}
<div class="callout {{ 'ok' if analysis.robust else 'fail' }}">
<strong>敏感性结论:{{ analysis.verdict }}</strong><br>
CAGR 跨度 <strong>{{ analysis.cagr_range_s }}</strong>
({{ analysis.cagr_min_s }} ~ {{ analysis.cagr_max_s }}),
相邻档最大跳变 <strong>{{ analysis.max_jump_s }}</strong>,
平滑度 <strong>{{ analysis.smoothness_s }}</strong>
{% if analysis.spikes %},检出 <strong>{{ analysis.spikes|length }}</strong> 处尖峰{% endif %}。
</div>
{% else %}
<div class="callout warn">扫描点数不足,无法评估参数敏感性。</div>
{% endif %}
<div class="kpi-grid">
<div class="kpi"><div class="label">参数组合数</div><div class="value">{{ point_count }}</div>
<div class="note">扫描网格 {{ sweep }}</div></div>
<div class="kpi"><div class="label">CAGR 区间</div>
<div class="value" style="font-size:17px">{{ analysis.cagr_min_s }} ~ {{ analysis.cagr_max_s }}</div>
<div class="note">跨度 {{ analysis.cagr_range_s }}</div></div>
<div class="kpi"><div class="label">平滑度</div>
<div class="value {{ 'gain' if analysis.robust else 'loss' }}">{{ analysis.smoothness_s }}</div>
<div class="note">越接近 1 越平滑(≥0.6 且无尖峰视为稳健)</div></div>
<div class="kpi"><div class="label">尖峰数</div>
<div class="value {{ 'loss' if analysis.spikes else 'gain' }}">{{ analysis.spikes|length if analysis.spikes else 0 }}</div>
<div class="note">某点显著高于左右邻居即为尖峰</div></div>
</div>
<div class="callout">
<strong>如何判读(plan.md §27)</strong>:若买入分位 P75→13%、P80→13.5%、P85→13.2%,
说明策略对参数<strong>不敏感</strong>,结论相对可信;
若 P79→18%、P80→20%、P81→8%,说明存在<strong>尖峰</strong>,很可能过拟合。
平滑度指标把这一判断从主观观察变成了可量化的数字。
</div>
{% if point_count > 1 %}
<div class="card">
<h2>绩效随参数的变化</h2>
<div id="chart-sweep" class="chart"></div>
</div>
{% endif %}
<div class="card">
<h2>扫描明细</h2>
<div class="table-scroll">
<table class="data">
<thead>
<tr>
{% for k in sweep_keys %}<th>{{ k }}</th>{% endfor %}
<th>CAGR</th><th>总收益</th><th>最大回撤</th>
<th>Sharpe</th><th>Calmar</th><th>Sortino</th><th>成交</th><th>年换手</th>
</tr>
</thead>
<tbody>
{% for p in points %}
<tr class="{{ 'spike-row' if p.is_spike else '' }}">
{% for v in p.key_values %}<td class="mono">{{ v }}</td>{% endfor %}
<td class="num {{ 'gain' if p.cagr_positive else 'loss' }}"><strong>{{ p.cagr }}</strong></td>
<td class="num">{{ p.total_return }}</td>
<td class="num loss">{{ p.max_drawdown }}</td>
<td class="num">{{ p.sharpe }}</td>
<td class="num">{{ p.calmar }}</td>
<td class="num">{{ p.sortino }}</td>
<td class="num">{{ p.trade_count }}</td>
<td class="num">{{ p.turnover }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if analysis.spikes %}
<div class="callout fail">
<strong>检出的尖峰:</strong>
<ul style="margin:4px 0 0 18px">
{% for s in analysis.spikes %}
<li>第 {{ s.index }} 点 CAGR {{ s.cagr_s }},高于左右邻居均值 {{ s.excess_s }}
(邻居均值 {{ s.neighbour_mean_s }})</li>
{% endfor %}
</ul>
这意味着收益对参数的微小变化异常敏感,是过拟合的典型信号。
</div>
{% endif %}
</div>
<div class="card">
<h2>性能与参数的关系</h2>
<div class="chart-grid">
<div><h3>年化收益 CAGR</h3><div id="chart-cagr" class="chart short"></div></div>
<div><h3>最大回撤</h3><div id="chart-dd" class="chart short"></div></div>
<div><h3>Sharpe</h3><div id="chart-sharpe" class="chart short"></div></div>
<div><h3>成交笔数(换手)</h3><div id="chart-trades" class="chart short"></div></div>
</div>
</div>
<div class="card">
<h2>可复现性</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">敏感性 ID</td><td style="text-align:left" class="mono">{{ sens_id }}</td></tr>
<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">扫描网格</td><td style="text-align:left" class="mono">{{ sweep }}</td></tr>
<tr><td style="text-align:left">区间</td><td style="text-align:left">{{ period }}</td></tr>
<tr><td style="text-align:left">数据版本</td><td style="text-align:left" class="mono">{{ data_version }}</td></tr>
</tbody>
</table>
</div>
<div class="callout warn">
<strong>注意</strong>:敏感性结论依赖扫描区间长度与股票池规模。
若股票池仅数十只、区间仅数年,即使平滑度低也可能只是样本噪声 ——
应结合 Walk-forward 的样本外结果共同判断。
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
if (typeof echarts === 'undefined') return;
var C = {{ chart_colors | json }};
var P = {{ param_values | json }};
function lineChart(id, values, name, formatter, color, markSpikes) {
var el = document.getElementById(id);
if (!el) return;
var c = echarts.init(el);
c.setOption({
color: [color || C[0]],
grid: { left: 62, right: 20, top: 28, bottom: 40 },
tooltip: { trigger: 'axis', valueFormatter: formatter },
xAxis: { type: 'category', data: P, name: '{{ sweep_keys[0] if sweep_keys else "参数" }}',
axisLabel: { color: '#64748B' } },
yAxis: { type: 'value', axisLabel: { color: '#64748B', formatter: formatter },
splitLine: { lineStyle: { color: '#E9EEF6' } } },
series: [{
name: name, type: 'line', data: values, showSymbol: true, symbolSize: 7,
lineStyle: { width: 2 },
markPoint: markSpikes ? {
symbol: 'pin', symbolSize: 40,
data: {{ spike_points | json }}.map(function (i) {
return { coord: [i, values[i]], value: '尖峰', itemStyle: { color: '#DC2626' } };
})
} : undefined
}]
});
window.addEventListener('resize', function () { c.resize(); });
}
var pct = function (v) { return v == null ? '—' : (v * 100).toFixed(2) + '%'; };
var num = function (v) { return v == null ? '—' : Number(v).toFixed(2); };
lineChart('chart-cagr', {{ charts.cagr | json }}, 'CAGR', pct, C[0], true);
lineChart('chart-dd', {{ charts.max_drawdown | json }}, '最大回撤', pct, C[4], false);
lineChart('chart-sharpe', {{ charts.sharpe | json }}, 'Sharpe', num, C[2], false);
lineChart('chart-trades', {{ charts.trade_count | json }}, '成交笔数', num, C[1], false);
var el = document.getElementById('chart-sweep');
if (el) {
var c = echarts.init(el);
c.setOption({
color: C,
grid: { left: 66, right: 66, top: 44, bottom: 46 },
tooltip: { trigger: 'axis' },
legend: { top: 0, textStyle: { color: '#64748B' } },
xAxis: { type: 'category', data: P, axisLabel: { color: '#64748B' } },
yAxis: [
{ type: 'value', name: '收益/回撤', axisLabel: { color: '#64748B',
formatter: function (v) { return (v * 100).toFixed(0) + '%'; } },
splitLine: { lineStyle: { color: '#E9EEF6' } } },
{ type: 'value', name: 'Sharpe', axisLabel: { color: '#64748B' },
splitLine: { show: false } }
],
series: [
{ name: 'CAGR', type: 'line', data: {{ charts.cagr | json }},
showSymbol: true, symbolSize: 7, lineStyle: { width: 2.4, color: C[0] } },
{ name: '最大回撤', type: 'line', data: {{ charts.max_drawdown | json }},
showSymbol: true, symbolSize: 6, lineStyle: { width: 1.6, color: C[4] } },
{ name: 'Sharpe', type: 'line', yAxisIndex: 1, data: {{ charts.sharpe | json }},
showSymbol: true, symbolSize: 6, lineStyle: { width: 1.6, type: 'dashed', color: C[2] } }
]
});
window.addEventListener('resize', function () { c.resize(); });
}
})();
</script>
<style>
tr.spike-row td { background: #FEF2F2; font-weight: 600; }
</style>
{% endblock %}