修复: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` 无界扫描仍然很慢)。
This commit is contained in:
2026-10-05 16:19:07 +08:00
parent cf6d4d2c56
commit fdfdd152d8
23 changed files with 1678 additions and 93 deletions
+122 -2
View File
@@ -135,14 +135,30 @@ def test_verify_market_units_empty() -> None:
# ---------------------------------------------------------------------------
# 滤网:行业豁免
# 滤网:共用夹具
#
# ``_FakeRepo`` / ``_frame`` 同时服务「行业豁免」与「行业排除」两组测试,
# 所以单独放在这里,不属于任何一组。
# ---------------------------------------------------------------------------
class _FakeRepo:
def __init__(self, st: set[str] | None = None, susp: set[str] | None = None) -> None:
#: 行业黑名单自检要用的取值域(与 _frame 里的 industry 列保持同源)
_DEFAULT_INDUSTRIES = [
"银行", "煤炭开采", "白酒", "全国地产", "区域地产", "房产服务", "园区开发",
]
def __init__(
self,
st: set[str] | None = None,
susp: set[str] | None = None,
industries: list[str] | None = None,
) -> None:
self._st = st or set()
self._susp = susp or set()
self._industries = list(
self._DEFAULT_INDUSTRIES if industries is None else industries
)
def st_symbols(self, asof, include_delisting=True): # noqa: ANN001, ARG002
return self._st
@@ -150,6 +166,10 @@ class _FakeRepo:
def suspended_on(self, asof): # noqa: ANN001, ARG002
return self._susp
def stock_master(self) -> pd.DataFrame:
"""只为 ``MarketFilter`` 的行业名自检提供 ``industry`` 取值域。"""
return pd.DataFrame({"industry": self._industries})
def _frame(**kwargs) -> pd.DataFrame:
base = {
@@ -171,6 +191,81 @@ def _frame(**kwargs) -> pd.DataFrame:
return pd.DataFrame(base)
# ---------------------------------------------------------------------------
# 滤网:行业排除清单(黑名单)
#
# 回归背景:``backtest.yml: universe_exclusions.industries`` 写在库里,
# 但数据库**没有**「房地产业」这个取值 —— 它被拆成 全国地产 / 区域地产 /
# 房产服务 / 园区开发。写错名字不会报任何错,只是「一只都没排除」。
# ---------------------------------------------------------------------------
def _market_filter(exclude: list[str]):
from hdiv.core.config import MarketFilterConfig
from hdiv.universe.filters.market import MarketFilter
return MarketFilter(MarketFilterConfig(), exclude_industries=exclude)
def test_market_filter_excludes_listed_industries() -> None:
f = _market_filter(["全国地产", "区域地产"])
out = f.compute(
_frame(industry=["区域地产", "煤炭开采"]), _FakeRepo(), date(2024, 6, 28)
)
assert bool(out.passed.iloc[0]) is False, "区域地产必须被排除"
assert bool(out.passed.iloc[1]) is True, "未列入黑名单的行业不受影响"
assert "排除清单" in out.reasons["600036.SH"]
def test_industry_exclusion_is_the_reported_reason() -> None:
"""同时市值不足时,报出的必须是「行业被排除」。
若行业判定排在市值之后,被排除的股票会先以「市值不足」落选,
事后无法分辨「这个行业不做了」还是「这只真的不达标」。
"""
f = _market_filter(["全国地产"])
df = _frame(industry=["全国地产", "煤炭开采"], total_mv=[1e10, 8.8e11])
out = f.compute(df, _FakeRepo(), date(2024, 6, 28))
assert bool(out.passed.iloc[0]) is False
assert "排除清单" in out.reasons["600036.SH"]
assert "市值" not in out.reasons["600036.SH"]
def test_empty_exclusion_list_changes_nothing() -> None:
"""不配 = 与改动前逐字一致(本清单只做减法)。"""
out = _market_filter([]).compute(
_frame(industry=["全国地产", "煤炭开采"]), _FakeRepo(), date(2024, 6, 28)
)
assert out.passed.all()
def test_industry_exclusion_allows_all_four_real_estate_labels() -> None:
"""四个地产口径都要能被单独命中(配置里缺一个就少排一类)。"""
labels = ["全国地产", "区域地产", "房产服务", "园区开发"]
f = _market_filter(labels)
out = f.compute(
_frame(industry=["房产服务", "园区开发"]), _FakeRepo(), date(2024, 6, 28)
)
assert not out.passed.any()
def test_unknown_industry_name_raises_instead_of_silently_passing() -> None:
"""写错行业名(库里不存在的「房地产业」)必须报错。
这是本功能最容易踩的坑:名单写错时股票池看起来「排除了」,
实际一只没少 —— 静默失效。宁可直接失败。
"""
from hdiv.core.errors import ConfigError
f = _market_filter(["房地产业"])
with pytest.raises(ConfigError) as ei:
f.compute(_frame(), _FakeRepo(), date(2024, 6, 28))
msg = str(ei.value)
assert "房地产业" in msg
# 必须给出最接近的真实取值,否则用户只能自己去翻库
assert "全国地产" in msg, f"错误信息没给出候选:{msg}"
def test_risk_filter_exempts_banks_from_leverage() -> None:
from hdiv.core.config import RiskFilterConfig
from hdiv.universe.filters.risk import RiskFilter
@@ -252,6 +347,31 @@ def _div(symbol: str, end_year: int, ex_year: int, dps: float = 1.0, month: int
}
def test_duplicate_dividend_records_count_once() -> None:
"""同一 (symbol, ex_date) 的重复记录不得把年度 DPS / 总分红重复累加。
年度 DPS 决定 ``dps_cagr_5y`` 与 ``dps_volatility``,总现金分红决定
``payout_ratio`` 与 ``fcf_dividend_cover``(并直接决定选股),
因此重复记录必须与 ``ttm_dps_series`` 用同一份聚合口径。
"""
from hdiv.core.config import DividendFilterConfig
from hdiv.universe.filters.dividend import DividendFilter
cfg = DividendFilterConfig()
recs = [
_div("600036.SH", 2023, 2024, dps=1.0, month=7),
_div("600036.SH", 2023, 2024, dps=1.0, month=7), # 重复公告
]
s = DividendFilter._stats(recs, target_year=2023, asof=date(2024, 12, 31), cfg=cfg)
assert s["dps_by_year"][2023] == pytest.approx(1.0), "年度 DPS 被重复累加"
row = pd.Series({"n_income_attr_p": 5.0e9, "free_cashflow": 8.0e9})
p = DividendFilter._payout_and_cover(recs, row, date(2024, 12, 31), cfg=cfg)
# 总现金分红 = 每股 1.0 元 × 基准股本 10000 万股 = 1.0e8
assert p["total_cash_dividend"] == pytest.approx(1.0 * 10000.0 * 1e4)
assert p["payout_ratio"] == pytest.approx(1.0 * 10000.0 * 1e4 / 5.0e9)
def test_continuity_within_target_year() -> None:
"""FY2023 分红已在 2024-05 除权 → target=2023,连续 5 年。"""
from hdiv.core.config import DividendFilterConfig