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

195 lines
8.3 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 %}
{% 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 %}