从 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。
262 lines
10 KiB
HTML
262 lines
10 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block content %}
|
||
|
||
<div class="kpi-grid">
|
||
<div class="kpi">
|
||
<div class="label">当前股息率</div>
|
||
<div class="value {{ 'gain' if current_pct_num is not none and current_pct_num >= entry_p else '' }}">{{ current_yield }}</div>
|
||
<div class="note">TTM 每股分红 / 不复权收盘价</div>
|
||
</div>
|
||
<div class="kpi">
|
||
<div class="label">历史分位</div>
|
||
<div class="value {{ 'gain' if current_pct_num is not none and current_pct_num >= entry_p else '' }}">{{ current_pct }}</div>
|
||
<div class="note">当前值在自身历史分布中的位置</div>
|
||
</div>
|
||
<div class="kpi">
|
||
<div class="label">P75 / P25 参考</div>
|
||
<div class="value" style="font-size:18px">{{ p75 }} / {{ p25 }}</div>
|
||
<div class="note">策略买入 / 卖出阈值</div>
|
||
</div>
|
||
<div class="kpi">
|
||
<div class="label">样本观测</div>
|
||
<div class="value">{{ n_obs }}</div>
|
||
<div class="note">日频观测数({{ history_span }})</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% if signal_note %}
|
||
<div class="callout {{ signal_class }}">
|
||
<strong>策略信号判定:</strong>{{ signal_note }}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="card">
|
||
<h2>K线 · 股息率 · PE · PB · 回撤(plan.md §33 四联图)</h2>
|
||
<div id="chart-main" class="chart" style="height:720px"></div>
|
||
</div>
|
||
|
||
<div class="chart-grid">
|
||
<div class="card">
|
||
<h2>股息率历史分布(plan.md §9)</h2>
|
||
<div id="chart-hist" class="chart"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>股息率波动特征(plan.md §11)</h2>
|
||
<div id="chart-vol" class="chart"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>历史分布统计(plan.md §9 要求的分位)</h2>
|
||
<div class="table-scroll">
|
||
<table class="data">
|
||
<thead>
|
||
<tr>
|
||
<th style="text-align:left">指标</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>Mean</th><th>Std</th>
|
||
<th>当前值</th><th>当前分位</th><th>状态</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for r in dist_rows %}
|
||
<tr>
|
||
<td style="text-align:left">{{ r.label }}</td>
|
||
<td class="num">{{ r.window }}</td>
|
||
<td class="num">{{ r.n_obs }}</td>
|
||
<td class="num">{{ r.min }}</td>
|
||
<td class="num">{{ r.p10 }}</td>
|
||
<td class="num">{{ r.p25 }}</td>
|
||
<td class="num">{{ r.p50 }}</td>
|
||
<td class="num">{{ r.p75 }}</td>
|
||
<td class="num">{{ r.p90 }}</td>
|
||
<td class="num">{{ r.max }}</td>
|
||
<td class="num">{{ r.mean }}</td>
|
||
<td class="num">{{ r.std }}</td>
|
||
<td class="num"><strong>{{ r.current }}</strong></td>
|
||
<td class="num {{ 'gain' if r.pct_high else '' }}">{{ r.pct }}</td>
|
||
<td><span class="badge {{ 'OK' if r.status == 'OK' else 'WARN' }}">{{ r.status }}</span></td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chart-grid">
|
||
<div class="card">
|
||
<h2>分红质量(plan.md §14)</h2>
|
||
<div class="table-scroll">
|
||
<table class="data">
|
||
<thead><tr><th style="text-align:left">指标</th><th>取值</th><th style="text-align:left">说明</th></tr></thead>
|
||
<tbody>
|
||
{% for r in dividend_rows %}
|
||
<tr><td style="text-align:left">{{ r.label }}</td><td class="num">{{ r.value }}</td>
|
||
<td style="text-align:left" class="small muted">{{ r.note }}</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>安全边际分项得分(plan.md §15)</h2>
|
||
{% if score_rows %}
|
||
<div id="chart-score" class="chart short"></div>
|
||
<div class="table-scroll">
|
||
<table class="data">
|
||
<thead><tr><th style="text-align:left">分项</th><th>得分</th></tr></thead>
|
||
<tbody>
|
||
{% for s in score_rows %}
|
||
<tr><td style="text-align:left">{{ s.label }}</td><td class="num">{{ s.score }}</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
<div class="callout warn">分项数据不足,未计算得分(不使用残缺数据造分)</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
{% if missing %}
|
||
<div class="callout warn">
|
||
<strong>数据缺失提示:</strong>{{ missing }}
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
(function () {
|
||
if (typeof echarts === 'undefined') return;
|
||
var C = {{ chart_colors | json }};
|
||
var S = {{ series | json }};
|
||
var cur = {{ current_line | json }};
|
||
|
||
/* ---- 主图:K线 + 股息率 + PE + PB + 回撤,共享 X 轴 ---- */
|
||
var main = document.getElementById('chart-main');
|
||
if (main && S.dates && S.dates.length) {
|
||
var ch = echarts.init(main);
|
||
var zoom = [{ type: 'inside' }, { type: 'slider', height: 18, bottom: 8 }];
|
||
function grid(i, top, h) {
|
||
return { left: 62, right: 62, top: top, height: h };
|
||
}
|
||
ch.setOption({
|
||
color: C,
|
||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
||
legend: { top: 0, textStyle: { color: '#64748B' } },
|
||
grid: [grid(0, 34, 210), grid(1, 282, 110), grid(2, 424, 90), grid(3, 546, 90)],
|
||
xAxis: [
|
||
{ type: 'category', data: S.dates, gridIndex: 0, axisLabel: { show: false },
|
||
axisTick: { show: false } },
|
||
{ type: 'category', data: S.dates, gridIndex: 1, axisLabel: { show: false },
|
||
axisTick: { show: false } },
|
||
{ type: 'category', data: S.dates, gridIndex: 2, axisLabel: { show: false },
|
||
axisTick: { show: false } },
|
||
{ type: 'category', data: S.dates, gridIndex: 3, axisLabel: { color: '#64748B',
|
||
formatter: function (v) { return 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: function (v) { return (v * 100).toFixed(1); } },
|
||
splitLine: { lineStyle: { color: '#E9EEF6' } } },
|
||
{ scale: true, gridIndex: 2, name: 'PE', axisLabel: { color: '#64748B' },
|
||
splitLine: { lineStyle: { color: '#E9EEF6' } } },
|
||
{ scale: true, gridIndex: 3, name: '回撤%', axisLabel: { color: '#64748B',
|
||
formatter: function (v) { return (v * 100).toFixed(0); } },
|
||
splitLine: { lineStyle: { color: '#E9EEF6' } } }
|
||
],
|
||
dataZoom: zoom,
|
||
series: [
|
||
{ name: '收盘价', type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: S.close,
|
||
showSymbol: false, lineStyle: { width: 1.4, color: C[0] } },
|
||
{ name: '股息率', type: 'line', xAxisIndex: 1, yAxisIndex: 1, data: S.dv_yield,
|
||
showSymbol: false, lineStyle: { width: 1.4, color: C[1] },
|
||
areaStyle: { opacity: 0.12 },
|
||
markLine: cur.p75 == null ? undefined : {
|
||
silent: true, symbol: 'none',
|
||
data: [
|
||
{ yAxis: cur.p75, lineStyle: { color: C[4], type: 'dashed' },
|
||
label: { formatter: '买入 P75', color: C[4], position: 'insideEndTop' } },
|
||
{ yAxis: cur.p25, lineStyle: { color: C[3], type: 'dashed' },
|
||
label: { formatter: '卖出 P25', color: C[3], position: 'insideEndBottom' } }
|
||
]
|
||
} },
|
||
{ name: 'PE(TTM)', type: 'line', xAxisIndex: 2, yAxisIndex: 2, data: S.pe_ttm,
|
||
showSymbol: false, lineStyle: { width: 1.2, color: C[2] } },
|
||
{ name: '回撤', type: 'line', xAxisIndex: 3, yAxisIndex: 3, data: S.drawdown,
|
||
showSymbol: false, lineStyle: { width: 1.2, color: C[4] },
|
||
areaStyle: { opacity: 0.1 } }
|
||
]
|
||
});
|
||
window.addEventListener('resize', function () { ch.resize(); });
|
||
}
|
||
|
||
/* ---- 直方图 ---- */
|
||
var hist = document.getElementById('chart-hist');
|
||
if (hist) {
|
||
var h = {{ histogram | json }};
|
||
var c2 = echarts.init(hist);
|
||
c2.setOption({
|
||
color: C,
|
||
grid: { left: 56, right: 24, top: 28, bottom: 40 },
|
||
tooltip: { trigger: 'axis' },
|
||
xAxis: { type: 'category', data: h.labels, name: '股息率',
|
||
axisLabel: { color: '#64748B' } },
|
||
yAxis: { type: 'value', name: '天数', axisLabel: { color: '#64748B' },
|
||
splitLine: { lineStyle: { color: '#E9EEF6' } } },
|
||
series: [{ type: 'bar', data: h.counts, itemStyle: { color: C[0] }, barMaxWidth: 26,
|
||
markLine: cur.p75 == null ? undefined : { silent: true, symbol: 'none',
|
||
data: [{ xAxis: h.current_bucket,
|
||
lineStyle: { color: C[4], type: 'dashed' },
|
||
label: { formatter: '当前', color: C[4] } }] } }]
|
||
});
|
||
window.addEventListener('resize', function () { c2.resize(); });
|
||
}
|
||
|
||
/* ---- 波动率 ---- */
|
||
var vol = document.getElementById('chart-vol');
|
||
if (vol) {
|
||
var v = {{ vol_chart | json }};
|
||
var c3 = echarts.init(vol);
|
||
c3.setOption({
|
||
color: C,
|
||
grid: { left: 60, right: 24, top: 28, bottom: 40 },
|
||
tooltip: { trigger: 'axis' },
|
||
xAxis: { type: 'category', data: v.labels, axisLabel: { color: '#64748B' } },
|
||
yAxis: { type: 'value', axisLabel: { color: '#64748B' },
|
||
splitLine: { lineStyle: { color: '#E9EEF6' } } },
|
||
series: [{ type: 'bar', data: v.values, itemStyle: { color: C[1] }, barMaxWidth: 40,
|
||
label: { show: true, position: 'top', color: '#0F172A', fontSize: 10 } }]
|
||
});
|
||
window.addEventListener('resize', function () { c3.resize(); });
|
||
}
|
||
|
||
/* ---- 得分雷达 ---- */
|
||
var sc = document.getElementById('chart-score');
|
||
if (sc) {
|
||
var d = {{ score_chart | json }};
|
||
var c4 = echarts.init(sc);
|
||
c4.setOption({
|
||
color: C,
|
||
tooltip: {},
|
||
radar: {
|
||
indicator: d.map(function (x) { return { name: x.name, max: 100 }; }),
|
||
axisName: { color: '#64748B', fontSize: 11 },
|
||
splitLine: { lineStyle: { color: '#E9EEF6' } },
|
||
splitArea: { areaStyle: { color: ['#FFFFFF', '#F8FAFC'] } }
|
||
},
|
||
series: [{ type: 'radar', data: [{ value: d.map(function (x) { return x.value; }),
|
||
name: '安全边际', areaStyle: { opacity: 0.2 },
|
||
lineStyle: { color: C[0] }, itemStyle: { color: C[0] } }] }]
|
||
});
|
||
window.addEventListener('resize', function () { c4.resize(); });
|
||
}
|
||
})();
|
||
</script>
|
||
{% endblock %}
|