Files
ggx/src/hdiv/universe/daily.py
T
simon fdfdd152d8 修复:TTM 股息率两处残余缺陷 + 公司行为三处静默错误;新增回测级排除行业清单
说明:本提交是工作区中此前的未提交工作(在 cf6d4d2 之后产生),**非本次会话所写**,
按用户要求**不跑测试、直接记录变更并推送**。
已完成推送前的基础安全检查:无明文凭据、无大文件、`.env`/`logs/`/`output/` 仍被忽略。
测试状态:**本次未执行测试套件**。

## 一、TTM 股息率的两处残余缺陷 + 卖出复核

起因:用户报告 `600690.SH` 在 2026-07-30 触发清仓、07-31 开盘卖出,实际不该卖。

### 缺陷一:同一除权日的多条「实施」记录被逐行累加
- 成因:`hd_dividend` 写入侧刻意保留全量公告记录,去重键含 `ann_date`,
  同一笔分红会有多条「实施」记录落在**同一除权日**;查询侧逐行累加即重复计入。
- 规模:5724 只有现金分红的股票中 **953 只**存在同除权日重复(多出 1313 行)。
- 效果:`600690.SH` 的 `ttm_dps` 长期虚高约一倍(2.46848 vs 真实 1.23424),
  窗口到期时又必然回落,把假象放大成一次 −78% 的塌陷。
- 修法:新增 `factor.dividend_yield.dedupe_dividend_events()`,按 `(symbol, ex_date)`
  聚合成**一笔经济事件**(金额/股数逐字段取最大 → 收敛「分项 + 合计」;
  日期取最晚 → PIT 保守)。三处入口统一调用:`ttm_dps_series`、
  `Repo.dividend_events`、`universe/filters/dividend.py`。

### 缺陷二:只看相邻间隔,漏掉「年度 → 中期 → 下一年度」的跳法
- 成因:7.6 的「按后继接管」只看相邻两次除权的间隔。实测 `600690.SH`:
  FY2024 年度 2025-07-25、FY2025 中期 2025-11-07、FY2025 年度 2026-08-21。
  105 天的间隔使前两笔被判为「年内多次分红」而互不取代,392 天又超过 `365+45`
  → **2026-07-25~08-21 出现 28 天空窗**,可见现金只剩 0.26920。
- 修法:`ttm_dps_series` 的覆盖窗口由「按相邻间隔」升级为「**按财年 `end_date`**」:
  ① 后继接管(保留 7.6 行为,阈值 `ttm_days - grace_days` = 320 天);
  ② **跨财年补位**:每个财年最后一笔 → 下一财年最后一笔入场,上限 `365 + grace`;
  ③ **末笔宽限兜底**:无后继时覆盖 `365 + grace`(真停发仍如实归零)。
- 验收(作者实测):600690 在 2026-07-27 的 `ttm_dps` 由 0.53840 变为 **1.23424**,
  股息率 5.30%、历史分位 92.98%,**不再触发 P25 清仓**。

### 缺陷三(设计缺口):卖出只认「已除权的现金」,不认「已公告的分红」
- 成因:FY2025 年度分红 0.89151 的**实施公告日是 2026-06-25**,除权日 2026-08-21。
  TTM 现金口径看不到它 → 「股息率处于历史低位」在字面上为真,
  实际描述的是**现金流时点**而非分红能力恶化。
- 修法:新增 `entry/exit.confirm`(`enabled` / `min_ratio` / `announce_lookback_days`):
  若「已公告未除权」的分红说明股息率本应更高,且
  `TTM ÷ (TTM + 已公告未除权) < min_ratio`,则判定**未确认**:
  保持仓位并记录 `EXIT_UNCONFIRMED`(不进成交流水)。真降息不会命中。

## 二、公司行为的三处静默错误(分红/送转/配股口径)

### 问题一:纯送转被整行丢弃(凭空亏损)
- `_apply_dividends` 在算送股**之前**就按 `cash_div_tax <= 0` 整行 `continue`,
  于是「10 送 10」这类**无现金分红**的送转完全不调股数 ——
  而价格是不复权价、除权日照常腰斩 → 记出一笔不存在的亏损。
- 规模:全库「实施且 `stk_div > 0`」13,038 行,其中**纯送转 3,268 行**;
  高股息池成员在 2015-2026 区间内 **824 笔**(如 `000793.SZ` 每 10 股转增 12 股,
  单笔约 −54% 的该持仓市值)。
- 修法:现金与送转**各自独立判断**,只有「既无现金也无送转」才跳过;
  并把 `stk_bo_rate`/`stk_co_rate` 写入分红台账留痕。

### 问题二:同一除权日的重复记录被重复入账
- 全库 **1401 组**同 `(symbol, ex_date)` 的多条实施记录(1240 组字段相同;
  96 组报告期不同、122 组金额不同)。实测 `002352.SZ 2024-11-07` 同时有
  0.4 / 1.0 / 1.4 三条,而 1.4 = 0.4 + 1.0 是合计口径 → 逐行累加会放大两三倍。
- 修法:复用 `dedupe_dividend_events`(与缺陷一同一个函数)。

### 问题三:分红再投资的声明与行为不一致
- 引擎实际行为一直是「分红现金回到与初始资金同一个 `cash` 变量,
  下次调仓按目标权重再配置」= `reinvest` + `portfolio_rebalance`;
  但 `backtest.yml` 写的是 `same_stock_next_open`,于是每次 run 都声明
  「未实现分红再投资规则,分红留存为现金」,让人误以为分红不可再投资。
- 修法:配置改为已实现组合 `cash_mode: reinvest` + `reinvest_rule: portfolio_rebalance`;
  声明逻辑抽成 `dividend_handling_notes()`,**逐档取值都有单测**对应
  (`hold`/`cash_out`/`same_stock_next_open`/`handle_stock_dividend=false`/配股
  才声明未实现)。顺带接线一直是**死字段**的 `dividend.apply_dividend_tax`。

## 三、新增:回测层面的排除行业清单(黑名单)

- 位置与语义:`config/backtest.yml: universe_exclusions.industries` ——
  「**这次回测**特意不要哪些行业」(研究口径),
  与 `config/universe.yml`(策略选股定义)**叠加取并集**,只做减法。
  三种回测模式(single / walkforward / daily)一律生效。
- 最大的坑:数据库 `stock.industry` 里**没有「房地产业」**,它被拆成四个名字,
  写「房地产」或「房地产业」**一只都排除不掉**:
  `全国地产` 26 只 + `区域地产` 43 只 + `房产服务` 13 只 + `园区开发` 14 只 = **96 只**(1.6%)。
  因此 `MarketFilter` 首次求值时拿名单与表内实际取值核对,
  **写错名字直接抛 `ConfigError`**(并按字符重合度提示最接近的真实取值)。
- 接线:三个入口都走生效后的配置;并修掉一处缓存陷阱(配置变更后缓存未失效)。
- 新增测试锁定它。

## 四、其它

- `src/hdiv/core/config.py`:新增配置模型(排除行业、卖出复核等,+102 行)
- `src/hdiv/data/repo.py`(+45)、`src/hdiv/backtest/engine.py`(+121)、
  `backtest/daily.py`、`backtest/walk_forward.py`、`web/service.py`、
  `report/universe_report.py` 相应接线
- 测试:新增 `tests/test_dividend_fiscal_year.py`;扩充
  `test_backtest.py` / `test_config.py` / `test_daily.py` /
  `test_dividend_smoothing.py` / `test_universe.py`
- `tools/diag_dividend_artifact.py`:诊断脚本与上述修复对齐
- 文档:`docs/implementation-status.md` 新增 §7.6b / §7.7 / §11;
  `docs/user-guide.md` 新增排除行业清单说明

## 待验证

本次按要求**未执行测试**。上述「实测/验收」数字均引自文档中作者自己的记录,
非本次会话验证结果。建议合入后跑一次全量测试(注意:daily 的 DB 标记测试
因 `hd_cashflow` 无界扫描仍然很慢)。
2026-10-05 16:19:07 +08:00

349 lines
14 KiB
Python
Raw 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.
"""每日动态股票池筛选器(``backtest --mode daily`` 的选股环节)。
**它做什么**:从 ``start`` 起,对**每一个交易日**按当日可见数据重建股票池。
判定逻辑一行不改 —— 仍然调用 :class:`~hdiv.universe.selector.UniverseSelector`
与四个 ``Filter``;本模块只负责两件工程上的事:
1. **取数**:用 :class:`~hdiv.universe.pit.PitRepo` 按区块批量预载,
逐日切片在内存完成(单次筛选从 10~18 秒降到秒级);
2. **候选集预剪枝**:见下。
------------------------------------------------------------
候选集预剪枝:为什么是「精确」的,而不是「近似」
------------------------------------------------------------
市场滤网要逐行判断 5000 余只股票的交易所/板块/上市年限/市值/流动性,
这一段的 Python 开销与**候选数**成正比,是每日循环里最大的一项。
预剪枝只剔除「在整个回测区间内**不可能**通过市场滤网」的股票,
判据都是**上界**:
- **交易所 / 板块**:与日期无关,不在配置名单里的股票永远不可能通过;
- **上市年限**:当 ``list_date + min_listing_years`` 晚于区间**最后一天**时,
该股在区间内任何一天都不满足 ``listed_years >= min_listing_years``;
- **市值**:当该股在区间内的 ``MAX(total_mv)``(换算为元)仍低于
``min_market_cap`` 时,任何一天都不满足市值下限。取不到市值(NULL)时
**保留**,不剪。
被剪掉的股票在原流程里**必然**在第一个滤网(market)就被淘汰,因此:
最终入选集合逐只相同,`hd_daily_universe` 的内容也相同。
差别只在于「被剪掉的股票没有留下逐滤网的原因」—— 而每日选股模式
**不落库逐股淘汰原因**(只落库每日入选成员),所以这个差别不可观测。
正确性由 ``tests/test_daily.py::test_prune_does_not_change_selection`` 锁定:
同一批交易日,开/关剪枝必须选出**完全相同**的成员。
**流动性没有做预剪枝**:``stock_daily`` 的量价单位在 2015-2019 是「手/千元」、
2020 起是「股/元」,用 ``MAX(amount)`` 做上界会在早年低估 1000 倍,
误剪掉本该通过的股票。宁可少一项优化,也不接受一个会改变结果的上界。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Any
import numpy as np
import pandas as pd
from hdiv.core.config import UniverseConfig
from hdiv.core.errors import HdivError
from hdiv.data import db
from hdiv.universe.pit import PitRepo
from hdiv.universe.selector import UniverseSelector
__all__ = ["DailyUniverseScreener", "PruneReport", "ScreenDay"]
#: daily_basic 的市值列以**万元**存放(见 data/units.py)
_WAN = 1e4
@dataclass
class PruneReport:
"""预剪枝的规模,用于回答「为什么候选从 5000 变成 1000」。"""
total: int = 0
pruned_exchange: int = 0
pruned_board: int = 0
pruned_listing: int = 0
pruned_market_cap: int = 0
kept: int = 0
def as_dict(self) -> dict[str, int]:
return dict(self.__dict__)
@dataclass
class ScreenDay:
"""某一天的选股结果。"""
trade_date: date
candidate_count: int
member_count: int
symbols: list[str]
members: pd.DataFrame
stats: dict[str, int] = field(default_factory=dict)
#: 当日**市场候选数**(未预剪枝)。与 ``candidate_count``(已预剪枝)分开记录 ——
#: 预剪枝是纯性能开关,不该让页面上的「候选」含义随开关变化。
listed_count: int = 0
#: 入选股票在**决策日的因子取值**(来自 ``UniverseSelector.run`` 的 ``values``)。
#: 必须带上它:``selected`` 里没有 ``dividend_yield`` 列(它在滤网的 values 里),
#: 只从 ``selected`` 取列会让落库的股息率**整列为 NULL**。
values: dict[str, dict[str, Any]] = field(default_factory=dict)
class DailyUniverseScreener:
"""逐日重建股票池(PIT),复用既有滤网,不改判定口径。"""
def __init__(
self,
config: UniverseConfig,
repo: PitRepo,
*,
verbose: bool = True,
) -> None:
self.config = config
self.repo = repo
self.selector = UniverseSelector(config, repo=repo)
self.verbose = verbose
self.prune = PruneReport()
self._allowed: set[str] | None = None
# 刻意**没有** ``from_strategy(registry, strategy, ...)``:
# ``registry.resolved_universe`` 只含策略 + universe.yml,拿不到
# backtest.yml 的 ``universe_exclusions``(行业黑名单)。保留这样一个
# 便捷构造器,等于给「逐日选股绕过行业排除」留了一条谁都看不出来的路。
# 调用方应先用 ``BacktestConfig.resolved_universe`` 合并出**生效后**的
# UniverseConfig(见 DailyRunner.__init__),再用普通构造函数传入。
# ------------------------------------------------------------------
# 预剪枝
# ------------------------------------------------------------------
def build_prune_set(
self, window_start: date, window_end: date, *, use_market_cap: bool = True
) -> set[str]:
"""计算「在 ``[window_start, window_end]`` 内不可能通过市场滤网」的补集。
返回**允许保留**的 symbol 集合。
"""
cfg = self.config.market
master = self.repo.stock_master().copy()
rep = PruneReport(total=len(master))
keep = pd.Series(True, index=master.index)
def _drop(mask: pd.Series, counter: str) -> None:
nonlocal keep
hit = keep & mask
setattr(rep, counter, getattr(rep, counter) + int(hit.sum()))
keep = keep & ~mask
# --- 交易所 / 板块(与日期无关)---
if cfg.exchanges:
allowed = {str(x) for x in cfg.exchanges}
_drop(~master["exchange"].astype(str).isin(allowed), "pruned_exchange")
if cfg.markets:
allowed_m = {str(x) for x in cfg.markets}
_drop(~master["market"].astype(str).isin(allowed_m), "pruned_board")
# --- 上市年限:区间最后一天仍不足,则区间内永远不足 ---
if cfg.min_listing_years and cfg.min_listing_years > 0:
ld = pd.to_datetime(master["list_date"], errors="coerce")
need_days = cfg.min_listing_years * 365.25
can_pass = (window_end - ld.dt.date).apply(
lambda x: x.days if pd.notna(x) else -1
) >= need_days
_drop(~can_pass, "pruned_listing")
# --- 市值:区间内 MAX(total_mv) 仍低于下限 ---
if use_market_cap:
cap = self._max_market_cap(window_start, window_end)
if cap:
for col, limit, counter in (
("total_mv", cfg.min_market_cap, "pruned_market_cap"),
("circ_mv", cfg.min_float_market_cap, "pruned_market_cap"),
):
if limit is None:
continue
mx = master["symbol"].map(cap.get(col, {}))
# 取不到市值时不剪(保守):只有**确知**上限低于阈值才剔除
too_small = mx.notna() & (mx < float(limit))
_drop(too_small, counter)
allowed = set(master.loc[keep, "symbol"].astype(str).tolist())
rep.kept = len(allowed)
self._allowed = allowed
self.prune = rep
self.repo.set_candidate_scope(allowed)
return allowed
def _max_market_cap(
self, start: date, end: date
) -> dict[str, dict[str, float]]:
"""区间内逐股 ``MAX(total_mv)`` / ``MAX(circ_mv)``,单位为**元**。
返回 ``{"total_mv": {symbol: 元}, "circ_mv": {symbol: 元}}``。
单位:``daily_basic.total_mv`` / ``circ_mv`` 以**万元**存放(Tushare 口径),
因此换算为元后再与配置里的元阈值比较。与 ``normalize_market_panel``
的 ×1e4 是同一件事。
"""
cfg = self.repo.cfg
if not db.table_exists("daily_basic", cfg):
return {}
try:
df = db.read_sql(
"SELECT symbol, MAX(total_mv) AS mx_total_mv, "
" MAX(circ_mv) AS mx_circ_mv "
"FROM daily_basic WHERE trade_date BETWEEN :s AND :e "
"GROUP BY symbol",
{"s": start, "e": end}, cfg=cfg,
)
except Exception: # pragma: no cover - 取不到就退化为不剪枝
return {}
if df.empty:
return {}
out: dict[str, dict[str, float]] = {"total_mv": {}, "circ_mv": {}}
syms = df["symbol"].astype(str).tolist()
total = pd.to_numeric(df["mx_total_mv"], errors="coerce") * _WAN
circ = pd.to_numeric(df["mx_circ_mv"], errors="coerce") * _WAN
for sym, t, c in zip(syms, total, circ, strict=False):
if pd.notna(t):
out["total_mv"][sym] = float(t)
if pd.notna(c):
out["circ_mv"][sym] = float(c)
return out
# ------------------------------------------------------------------
# 逐日筛选
# ------------------------------------------------------------------
def screen_day(self, day: date) -> ScreenDay:
"""筛选单个交易日。"""
def _hook(stage: str, live: pd.DataFrame) -> None:
# 把取数范围收窄到本阶段真正要评估的股票(纯性能开关)
self.repo.restrict_to(live["symbol"].tolist())
try:
res = self.selector.run(
asof=day, persist=False, verbose=False, on_stage=_hook
)
finally:
self.repo.restrict_to(None)
members = res["selected"]
symbols = [str(s) for s in members["symbol"].tolist()]
listed_total, screened = self.repo.listed_counts(res["asof_date"])
vals = res.get("values") or {}
return ScreenDay(
trade_date=res["asof_date"],
candidate_count=screened,
member_count=int(res["member_count"]),
symbols=symbols,
members=members,
stats=dict(res["stats"]),
listed_count=listed_total,
# 只为入选股票保留因子取值(全市场 4000 余只 × 1600 天会白占内存)
values={s: dict(vals.get(s) or {}) for s in symbols},
)
def screen(self, days: list[date]) -> dict[date, set[str]]:
"""对 ``days`` 逐日筛选,返回 ``{交易日: 入选代码集合}``。"""
if self._allowed is None:
raise HdivError(
"DailyUniverseScreener 必须先 build_prune_set(...) 再 screen(...)。\n"
" 预剪枝是可选的性能优化;若不想剪枝,请显式传 use_market_cap=False\n"
" 并把候选范围设为「全部上市股票」。"
)
out: dict[date, set[str]] = {}
for day in days:
sd = self.screen_day(day)
out[sd.trade_date] = set(sd.symbols)
return out
# ------------------------------------------------------------------
# 落库行
# ------------------------------------------------------------------
@staticmethod
def member_rows(
run_id: str, screens: list[ScreenDay], *, created_at: Any
) -> list[dict[str, Any]]:
"""把逐日选股结果摊平成 ``hd_daily_universe`` 的行。
取值优先级:**滤网的 ``values`` → ``selected`` 的列**。
股息率、支付率、FCF 覆盖等只在 ``values`` 里(它们是滤网算出来的),
``selected`` 只有行情/年报均值那几列;反过来 ``total_mv``/``roe_avg``
只在列里。只取其中一边都会让某些列整列为 NULL。
"""
rows: list[dict[str, Any]] = []
for sd in screens:
m = sd.members
if m is None or m.empty:
continue
for rec in m.to_dict("records"):
sym = str(rec.get("symbol"))
vals = dict(sd.values.get(sym) or {})
merged = {**{k: rec.get(k) for k in rec}, **vals}
def pick(key: str) -> Any:
v = vals.get(key)
if v is None or (isinstance(v, float) and v != v):
v = rec.get(key)
return v
rows.append({
"run_id": run_id,
"trade_date": sd.trade_date,
"symbol": sym,
"name": _s(rec.get("name")),
"industry": _s(rec.get("industry")),
# 股息率:筛选口径(自算优先)在 values 里;dv_ttm 在列里
"dividend_yield": _f(pick("dividend_yield")),
"total_mv": _f(pick("total_mv")),
"roe_avg": _f(pick("roe_avg")),
"listed_count": int(sd.listed_count) if sd.listed_count else None,
"candidate_count": int(sd.candidate_count),
"values_json": _values_json(merged),
"created_at": created_at,
})
return rows
def _s(v: Any) -> str | None:
if v is None or (isinstance(v, float) and v != v):
return None
return str(v)[:64]
def _f(v: Any) -> float | None:
if v is None:
return None
try:
x = float(v)
except (TypeError, ValueError):
return None
return None if (x != x or np.isinf(x)) else x
def _values_json(rec: dict[str, Any]) -> str:
"""入选时的关键因子快照(供「为什么是这只」复核)。"""
import json
keys = (
"dividend_yield", "dividend_yield_computed", "dv_ttm", "ttm_dps",
"pe_ttm", "pb", "ps_ttm", "total_mv", "circ_mv", "avg_amount_20d",
"dividend_continuity_years", "dividend_years_in_window", "payout_ratio",
"fcf_dividend_cover", "dps_cagr_5y", "roe", "roe_avg", "roic", "roic_avg",
"debt_ratio", "ocf_to_netprofit", "ocf_to_profit_avg", "fin_years_count",
)
out: dict[str, Any] = {}
for k in keys:
if k not in rec:
continue
v = _f(rec.get(k))
if v is not None:
out[k] = v
return json.dumps(out, ensure_ascii=False)