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

从 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
+270
View File
@@ -0,0 +1,270 @@
"""股票池报告装配器(plan.md §34「股票池」页面)。"""
from __future__ import annotations
from datetime import date, datetime
from pathlib import Path
from typing import Any
import pandas as pd
from hdiv.core.config import config_hash, load_config
from hdiv.data import db
from hdiv.data.repo import Repo
from hdiv.report.renderer import Provenance, Renderer, query
# 滤网中文说明(呈现用,非业务逻辑)
_FILTER_DESC = {
"market": "交易所 / 板块 / 上市年限 / 市值 / 流动性 / 当日可交易",
"risk": "ST / 退市 / 停牌 / 净资产为负 / 资产负债率(金融豁免)",
"dividend": "股息率 / 连续分红年数 / 窗口内分红次数 / 支付率 / FCF 覆盖",
"quality": "年均 ROE / ROIC / 毛利率 / 净利率 / 经营现金流对利润(金融豁免)",
}
def build_universe_report(run_id: str, *, cfg: Any = None, top_rejected: int = 400) -> Path:
if cfg is None:
db.load_dotenv_once()
cfg = load_config("datasource")
r = Renderer()
prov = Provenance()
head = query(
"SELECT run_id, name, asof_date, mode, candidate_count, member_count, "
" config_json, config_hash, data_version, stats_json, status, created_at "
"FROM hd_universe_run WHERE run_id = :rid",
{"rid": run_id}, prov=prov, label="股票池运行头", cfg=cfg,
)
if head.empty:
raise ValueError(f"找不到 run_id={run_id}")
h = head.iloc[0]
members_df = query(
"SELECT symbol, name, industry, passed, fail_stage, fail_reason, values_json, filter_json "
"FROM hd_universe_member WHERE run_id = :rid",
{"rid": run_id}, prov=prov, label="股票池成员", cfg=cfg,
)
selected = members_df[members_df["passed"] == 1].copy()
rejected = members_df[members_df["passed"] == 0].copy()
# 逐股指标(从 values_json 展开)
def vals(s: str) -> dict:
import json
try:
return json.loads(s) if s else {}
except Exception:
return {}
sel_vals = selected["values_json"].map(vals) if not selected.empty else pd.Series(dtype=object)
if not selected.empty:
selected["dividend_yield"] = sel_vals.map(lambda d: d.get("dividend_yield"))
selected["continuity"] = sel_vals.map(
lambda d: d.get("dividend_continuity_years")
)
selected["roe"] = sel_vals.map(lambda d: d.get("roe"))
selected["debt_ratio"] = sel_vals.map(lambda d: d.get("debt_ratio"))
selected["payout_ratio"] = sel_vals.map(lambda d: d.get("payout_ratio"))
selected = selected.sort_values("dividend_yield", ascending=False, na_position="last")
# 入选股的市场指标
market_cols = {}
if not selected.empty:
asof = pd.to_datetime(h["asof_date"]).date()
repo = Repo(cfg)
panel = repo.market_panel(asof)
m = panel.set_index("symbol")
for c in ("pe_ttm", "pb", "total_mv"):
market_cols[c] = selected["symbol"].map(
lambda s: float(m.at[s, c]) if s in m.index and pd.notna(m.at[s, c]) else None
)
member_rows = []
for i, row in selected.iterrows():
member_rows.append(
{
"symbol": row["symbol"],
"name": row["name"],
"industry": row["industry"] or "—",
"dividend_yield": _pct(row.get("dividend_yield")),
"pe_ttm": _num(market_cols.get("pe_ttm", pd.Series()).get(i), 2),
"pb": _num(market_cols.get("pb", pd.Series()).get(i), 2),
"total_mv": _yi(market_cols.get("total_mv", pd.Series()).get(i)),
"continuity": _num(row.get("continuity"), 0),
"roe": _pct(row.get("roe")),
"debt_ratio": _pct(row.get("debt_ratio")),
"payout": _pct(row.get("payout_ratio")),
}
)
# 滤网漏斗
stats = {}
import json as _json
try:
stats = _json.loads(h["stats_json"]) if h["stats_json"] else {}
except Exception:
stats = {}
incoming = int(h["candidate_count"])
stages = []
for name in ("market", "risk", "dividend", "quality"):
removed = int(stats.get(name, 0))
stages.append(
{
"name": name,
"incoming": incoming,
"removed": removed,
"survived": incoming - removed,
"rate": f"{removed / incoming:.1%}" if incoming else "—",
"desc": _FILTER_DESC.get(name, ""),
}
)
incoming -= removed
funnel = [{"name": "候选", "value": int(h["candidate_count"])}] + [
{"name": f"通过 {s['name']}", "value": s["survived"]} for s in stages
]
# 行业分布
industry_rows: list[dict[str, Any]] = []
industry_chart: list[dict[str, Any]] = []
if not selected.empty:
cand_by_ind = rejected["industry"].value_counts().to_dict()
sel_by_ind = selected["industry"].fillna("未知").value_counts()
tot = int(sel_by_ind.sum()) or 1
for ind, n in sel_by_ind.items():
cand_n = int(cand_by_ind.get(ind, 0)) + int(n)
industry_rows.append(
{
"industry": ind,
"selected": int(n),
"share": f"{n / tot:.1%}",
"candidates": cand_n,
"hit_rate": f"{n / cand_n:.1%}" if cand_n else "—",
}
)
industry_chart.append({"name": ind, "value": int(n)})
# 股息率直方图(入选 vs 候选)
yield_hist = None
all_vals = members_df["values_json"].map(vals)
cand_yield = pd.to_numeric(
all_vals.map(lambda d: d.get("dividend_yield")), errors="coerce"
)
sel_yield = pd.to_numeric(sel_vals.map(lambda d: d.get("dividend_yield")), errors="coerce") \
if not selected.empty else pd.Series(dtype=float)
if cand_yield.notna().sum() > 0:
edges = [0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.08, 0.10, 1.0]
labels = ["<1%", "1-2%", "2-3%", "3-4%", "4-5%", "5-6%", "6-8%", "8-10%", ">10%"]
cand_counts = pd.cut(cand_yield, bins=edges, labels=labels, right=False).value_counts()
sel_counts = pd.cut(sel_yield, bins=edges, labels=labels, right=False).value_counts()
yield_hist = {
"buckets": labels,
"series": [
{"name": "候选", "data": [int(cand_counts.get(x, 0)) for x in labels]},
{"name": "入选", "data": [int(sel_counts.get(x, 0)) for x in labels]},
],
}
rej = rejected.head(top_rejected)
rejected_rows = [
{
"symbol": row["symbol"],
"name": row["name"],
"industry": row["industry"] or "—",
"fail_stage": row["fail_stage"] or "—",
"fail_reason": row["fail_reason"] or "—",
}
for _, row in rej.iterrows()
]
asof_d = pd.to_datetime(h["asof_date"]).date()
filename = r.name_from("universe", asof=asof_d.isoformat(), run_id=run_id)
n_member = int(h["member_count"])
n_cand = int(h["candidate_count"])
return r.render(
"reports/universe.html",
{
"candidate_count": f"{n_cand:,}",
"member_count": n_member,
"pass_rate": f"{n_member / n_cand:.2%}" if n_cand else "—",
"asof_date": str(asof_d),
"warnings": _warnings(h, n_member),
"stages": stages,
"funnel": funnel,
"members": member_rows,
"industry_rows": industry_rows,
"industry_chart": industry_chart,
"yield_hist": yield_hist,
"rejected": rejected_rows,
"config_json": _pretty_json(h["config_json"]),
"config_hash": h["config_hash"],
"data_version": h["data_version"],
"provenance_count": len(prov.entries),
"provenance_pretty": _pretty_prov(prov),
"subtitle": f"筛选配置 {h['name']} · 求值时点 {asof_d}(Point-in-Time)",
},
filename,
report_type="universe",
title=f"股票池报告 · {asof_d}",
source_run_ids=[run_id],
provenance=prov,
cfg=cfg,
)
def _warnings(h: Any, n_member: int) -> list[str]:
out: list[str] = []
if n_member == 0:
out.append("股票池为空:筛选条件过严,或相关数据(分红/财报)尚未同步完成")
elif n_member < 10:
out.append(f"股票池仅 {n_member} 只,偏少;请确认财务与分红数据是否已全量同步")
if h["status"] == "WARN":
out.append("运行状态为 WARN,详见下方漏斗与配置")
return out
def _num(v: Any, dec: int = 2) -> str:
if v is None or (isinstance(v, float) and v != v) or pd.isna(v):
return "—"
try:
return f"{float(v):,.{dec}f}"
except (TypeError, ValueError):
return str(v)
def _pct(v: Any, dec: int = 2) -> str:
if v is None or (isinstance(v, float) and v != v) or pd.isna(v):
return "—"
try:
return f"{float(v) * 100:.{dec}f}%"
except (TypeError, ValueError):
return str(v)
def _yi(v: Any) -> str:
if v is None or (isinstance(v, float) and v != v) or pd.isna(v):
return "—"
return f"{float(v) / 1e8:,.1f}亿"
def _pretty_json(s: Any) -> str:
import json
try:
return json.dumps(json.loads(s), ensure_ascii=False, indent=2)
except Exception:
return str(s)
def _pretty_prov(prov: Provenance) -> str:
import json
lines = []
for i, e in enumerate(prov.entries, 1):
lines.append(f"[{i}] {e['label']}")
lines.append(f" {e['sql']}")
if e["params"]:
lines.append(f" 参数: {json.dumps(e['params'], ensure_ascii=False, default=str)}")
lines.append("")
return "\n".join(lines) or "(无查询记录)"