修复: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
+20 -6
View File
@@ -118,6 +118,12 @@ class DailyRunner:
self.strategy = self.registry.load(strategy_path)
self.bt: BacktestConfig = load_config("backtest")
self.daily: DailyConfig = self.bt.daily
#: 叠加了 backtest.yml ``universe_exclusions``(行业黑名单)之后的筛选配置。
#: 逐日选股必须用它 —— 直接用 registry.resolved_universe 会绕过行业排除,
#: 让「配置里排除了房地产」与「每天选出来的池子」互相矛盾。
self.universe_cfg = self.bt.resolved_universe(
self.registry.resolved_universe(self.strategy)
)
@classmethod
def from_strategy(cls, path: str | Path) -> DailyRunner:
@@ -245,9 +251,7 @@ class DailyRunner:
)
else:
# --- 保守预剪枝 ---
screener = DailyUniverseScreener.from_strategy(
self.registry, self.strategy, repo, verbose=False
)
screener = DailyUniverseScreener(self.universe_cfg, repo, verbose=False)
allowed = screener.build_prune_set(start, end)
if verbose:
p = screener.prune.as_dict()
@@ -370,15 +374,25 @@ class DailyRunner:
def _pools_cache_key(self, start: date, end: date, step: int) -> str:
"""选股缓存的指纹:**只由输入决定**,不含时间戳。
``step``(股票池重建频率)必须在键里:用 1 日/5 日筛出的池子是不同的输入,
共用一份缓存会静默给出错的股票池。信号频率(``--signal-every-n-days``)
**不在**键里 —— 它只影响模拟,不影响选股结果。
必须在键里的东西,都是**会改变选股结果**的输入:
- ``step``(股票池重建频率):用 1 日/5 日筛出的池子是不同的输入,
共用一份缓存会静默给出错的股票池;
- ``industry_exclusions``(行业黑名单):改名单就换了筛选口径。
它来自 ``backtest.yml`` 而 ``registry.hash_of(strategy)`` 只覆盖
策略 + ``universe.yml``,所以**必须显式加进键** —— 否则改了排除清单
却命中旧缓存,跑出来的是「排除了房地产之前」的池子,
而日志上写着已排除,属于最难发现的一类失效。
信号频率(``--signal-every-n-days``)**不在**键里 ——
它只影响模拟,不影响选股结果。
"""
return stable_id(
"dailypools",
self.strategy.strategy.id,
self.strategy.strategy.version,
self.registry.hash_of(self.strategy),
"excl:" + ",".join(self.universe_cfg.industry_exclusions),
str(start), str(end), str(int(step)),
)
+114 -7
View File
@@ -28,16 +28,20 @@ from hdiv.core.config import (
BacktestConfig,
CostConfig,
StrategyConfig,
config_hash,
load_config,
)
from hdiv.core.errors import DataGapError, HdivError
from hdiv.data import db
from hdiv.data.repo import Repo, data_version
from hdiv.data.sync.base import stable_id
from hdiv.factor.dividend_yield import build_dps_events, ttm_dps_series, ttm_params
from hdiv.factor.dividend_yield import (
build_dps_events,
dedupe_dividend_events,
ttm_dps_series,
ttm_params,
)
from hdiv.strategy.registry import StrategyRegistry
from hdiv.universe.selector import UniverseSelector
# ---------------------------------------------------------------------------
# 持仓与订单
@@ -232,7 +236,9 @@ class BacktestEngine:
# 默认拒绝;只有显式放行才执行,且必须把「含未来信息」写进 run 记录。
self.allow_lookahead_universe = allow_lookahead_universe
self.lookahead_universe_note: str | None = None
self.universe_cfg = self.registry.resolved_universe(strategy)
self.universe_cfg = self.bt_cfg.resolved_universe(
self.registry.resolved_universe(strategy)
)
# 实时画像闸门(PIT):关闭时整条链路不参与,回测行为与启用前一致
self.gate_cfg = strategy.entry.profile_gate
self.pit: Any = None
@@ -252,6 +258,10 @@ class BacktestEngine:
#: 买卖决策发生时计算并留痕个股画像(不区分是否在当日池内)
self.profile_on_trade = profile_on_trade
self.profile_window_years = profile_window_years
#: 已公告未除权的现金分红 {symbol: [(公告日, 每股金额)]},由 _prepare 填充
self.pending_div: dict[str, list[tuple[date, float]]] = {}
#: 被卖出复核拦下的清仓次数(修复 3b 的留痕)
self.exit_unconfirmed = 0
@classmethod
def from_strategy(cls, path: str | Path, **kw: Any) -> BacktestEngine:
@@ -340,6 +350,7 @@ class BacktestEngine:
bt.capital.initial,
),
"unimplemented": state["unimplemented"],
"exit_unconfirmed": state.get("exit_unconfirmed", 0),
"profile_gate": self.pit.stats() if self.pit is not None else None,
}
if self.pit is not None:
@@ -506,7 +517,10 @@ class BacktestEngine:
flush=True,
)
else:
selector = self.registry.selector(s)
# 用 self.universe_cfg 而不是 registry.selector(s):
# 前者已叠加 backtest.yml 的 universe_exclusions(行业黑名单),
# 后者只含策略/筛选配置 —— 走后者会让行业排除在回测里静默失效。
selector = UniverseSelector(self.universe_cfg, repo=self.repo)
for rd in refresh_dates:
res = selector.run(asof=rd, persist=False, verbose=False)
universe_by_refresh[rd] = set(res["selected"]["symbol"].tolist())
@@ -559,13 +573,49 @@ class BacktestEngine:
)
# --- 分红事件(含送转),用于持仓期间的现金与股数调整 ---
div_events = self.repo.dividend_events(days[0], days[-1])
# 必须先按经济事件聚合:同一 (symbol, ex_date) 可能有多条 `实施` 记录
# (全库 1401 组)。逐行入账会把同一笔现金分红重复计入、把送转股重复放大
# (实测 002352.SZ 2024-11-07 同时有 0.4/1.0/1.4 三条,合计口径 1.4)。
div_events = dedupe_dividend_events(self.repo.dividend_events(days[0], days[-1]))
div_events = div_events[div_events["symbol"].isin(set(all_syms))]
div_by_date: dict[date, list[dict]] = {}
for r in div_events.to_dict("records"):
ex = pd.to_datetime(r["ex_date"]).date()
div_by_date.setdefault(ex, []).append(r)
# --- 已公告未除权的分红(PIT)---
# 卖出复核用(s.exit.confirm):TTM 股息率在窗口边界上会因「上一年度
# 分红到期、本年度还没除权」而出现台阶。实测 600690.SH 2026-07-30:
# 可见现金只剩 0.26920,而 FY2025 年度 0.89151 早在 2026-06-25 就已
# 实施公告(除权日 2026-08-21)—— 只看已除权现金会把它误读成「分红
# 能力恶化」并清仓。这里把「当时确实可知」的已公告金额预载成
# {symbol: [(ann_date, per_share 累计)]},逐日累加后在 _evaluate 里复核。
self.pending_div = {}
if s.exit.confirm.enabled:
pend = self.repo.announced_dividends(
days[-1], lookback_days=s.exit.confirm.announce_lookback_days
)
if not pend.empty:
# 同一 (symbol, ex_date) 可能有多条公告记录 → 先按经济事件聚合
pend = dedupe_dividend_events(pend)
pend = pend[pend["symbol"].isin(set(all_syms))]
for r in pend.to_dict("records"):
# 用 **ann_date**(预案/股东大会通过/实施的首次公告日)作为可见起点:
# 实测 600690.SH 的 FY2025 年度分红 2026-06-25 就已「股东大会通过」
# 并公告,而 imp_ann_date 要到 2026-08-15 —— 若用后者,8 月 15 日
# 之前那笔客观存在的公告信息就被当成不可知,复核形同虚设。
# SQL 已保证 ann_date 非空,这里仍做 NaN 防御(NaT 与 date 比较会抛错)。
ann = r.get("ann_date")
if ann is None or pd.isna(ann):
continue
ann_d = pd.to_datetime(ann).date()
ps = float(r.get("cash_div_tax") or 0.0)
if ps <= 0:
continue
self.pending_div.setdefault(r["symbol"], []).append((ann_d, ps))
for sym in self.pending_div:
self.pending_div[sym].sort(key=lambda x: x[0])
# --- 停牌与涨跌停 ---
suspend = self._load_suspend(all_syms, days[0], days[-1])
limits = self._load_limits(all_syms, days[0], days[-1])
@@ -833,6 +883,7 @@ class BacktestEngine:
"total_dividend_net": float(div_df["net"].sum()) if not div_df.empty else 0.0,
"total_dividend_tax": float(div_df["tax"].sum()) if not div_df.empty else 0.0,
"unimplemented": sorted(unimplemented),
"exit_unconfirmed": int(self.exit_unconfirmed),
}
# ------------------------------------------------------------------
@@ -947,9 +998,25 @@ class BacktestEngine:
if target is None:
continue # 死区:保持仓位
if abs(target) <= 1e-9:
# 清仓前复核(修复 3b):TTM 股息率的窗口台阶可能来自
# 「上一年度分红到期、本年度还没除权」,而不是分红能力恶化。
# 若已公告未除权的分红足以把股息率抬高,则保持仓位。
ok, cinfo = self._exit_confirmed(sym, day, current)
if not ok:
out.append(Signal(
sym, day, "HOLD", 0.0, current, pct, price,
{**common, **{"exit_confirm": cinfo},
"rule": f"分位 {pct:.1f}% <= P{s.exit.yield_percentile:g},"
f"但已公告未除权分红 {cinfo['pending_dps']:.4f} 元/股"
f"(比值 {cinfo['ratio']:.2f} < {cinfo['min_ratio']:.2f})"
f"→ 未确认,保持仓位",
"reason_cn": "股息率回落主要由现金流时点造成,卖出未确认",
"skip_reason": "EXIT_UNCONFIRMED", "executed": False},
))
continue
out.append(self._trade_signal(Signal(
sym, day, "SELL", 0.0, current, pct, price,
{**common,
{**common, "exit_confirm": cinfo,
"rule": f"股息率历史分位 {pct:.1f}% <= P{s.exit.yield_percentile:g}",
"reason_cn": "股息率回落至历史低位区间,达到卖出阈值,清仓"},
), day))
@@ -1086,6 +1153,46 @@ class BacktestEngine:
"skip_reason": "PROFILE_GATE", "executed": False},
)
def _pending_dps(self, sym: str, day: date) -> float:
"""截至 ``day`` **已公告但尚未除权**的每股税后现金分红之和(PIT)。"""
rows = self.pending_div.get(sym)
if not rows:
return 0.0
total = 0.0
for ann_d, ps in rows:
if ann_d <= day:
total += ps
else:
break # 已按公告日升序排列
return total
def _exit_confirmed(
self, sym: str, day: date, current: float
) -> tuple[bool, dict[str, Any]]:
"""卖出复核(修复 3b):判断清仓信号是否被「已公告未除权分红」证伪。
语义:``current`` 是**已除权现金**口径的 TTM 股息率。若同时存在已公告、
除权日未到的分红,则「完整口径」应为 ``current + pending``。当
``current / (current + pending) < min_ratio`` 时,说明当前的低股息率
主要是现金流时点造成的 —— 保持仓位,记录 ``EXIT_UNCONFIRMED``。
真降息不会被拦:公告金额本身就低(甚至没有公告)时 ``pending ≈ 0``,
比值接近 1,照常清仓。
"""
c = self.strategy.exit.confirm
info: dict[str, Any] = {"enabled": bool(c.enabled)}
if not c.enabled:
return True, info
pending = self._pending_dps(sym, day)
total = current + pending
ratio = (current / total) if total > 0 else 1.0
info.update({"pending_dps": round(pending, 6), "ratio": round(ratio, 4),
"min_ratio": c.min_ratio})
if pending <= 0 or ratio >= c.min_ratio:
return True, info
self.exit_unconfirmed += 1
return False, info
def _reference_window(self, day: date) -> tuple[date, date] | None:
if self.frozen_reference is not None:
return self.frozen_reference
+12 -1
View File
@@ -34,6 +34,7 @@ from hdiv.data import db
from hdiv.data.repo import Repo, data_version
from hdiv.data.sync.base import stable_id
from hdiv.strategy.registry import StrategyRegistry
from hdiv.universe.selector import UniverseSelector
@dataclass
@@ -54,11 +55,21 @@ class WalkForwardRunner:
self.strategy = self.registry.load(strategy_path)
self.bt: BacktestConfig = load_config("backtest")
self.repo = Repo()
#: 叠加了 backtest.yml ``universe_exclusions``(行业黑名单)之后的筛选配置。
#: 训练段的冻结分布校准与各窗口引擎必须用**同一份**:否则冻结分布是在
#: 含被排除行业的池子上标定的,与测试段实际能买的池子口径不一致。
self.universe_cfg = self.bt.resolved_universe(
self.registry.resolved_universe(self.strategy)
)
@classmethod
def from_strategy(cls, path: str | Path) -> WalkForwardRunner:
return cls(path)
def selector(self) -> UniverseSelector:
"""用**生效后**的筛选配置(含 backtest.yml 的行业排除)建选择器。"""
return UniverseSelector(self.universe_cfg, repo=self.repo)
# ------------------------------------------------------------------
# 窗口切分
# ------------------------------------------------------------------
@@ -231,7 +242,7 @@ class WalkForwardRunner:
ttm_params,
)
sel = self.registry.selector(self.strategy)
sel = self.selector()
res = sel.run(asof=end, persist=False, verbose=False)
syms = res["selected"]["symbol"].tolist()
if not syms:
+102
View File
@@ -218,6 +218,13 @@ class UniverseConfig(StrictModel):
industry_exemptions: IndustryExemptionsConfig = Field(
default_factory=IndustryExemptionsConfig
)
#: 股票池行业**排除**清单(黑名单)。命中即在股票池阶段淘汰。
#: 默认空 = 不排除。回测运行时 config/backtest.yml 的 universe_exclusions
#: 会**叠加**到本字段上(见 BacktestConfig.resolved_universe),
#: 因此这里也可以直接写死一个全局排除集。
#: 名称必须与 stock.industry 逐字一致;「房地产业」在库里被拆成
#: 全国地产 / 区域地产 / 房产服务 / 园区开发 四个值。
industry_exclusions: list[str] = Field(default_factory=list)
market: MarketFilterConfig = Field(default_factory=MarketFilterConfig)
risk: RiskFilterConfig = Field(default_factory=RiskFilterConfig)
dividend: DividendFilterConfig = Field(default_factory=DividendFilterConfig)
@@ -560,6 +567,44 @@ class DailyConfig(StrictModel):
return self
class UniverseExclusionsConfig(StrictModel):
"""回测层面的股票池排除清单(黑名单)。
**为什么放在 backtest.yml 而不是 universe.yml**:universe.yml 描述的是
「高股息策略本身要求什么样的公司」(选股定义,与某一次研究无关);
本清单描述的是「这次回测特意不要哪些行业」(研究口径,例如规避地产周期)。
两者语义不同,所以分开存放,生效时取并集。
**刻意只支持精确匹配**:``stock.industry`` 是有限枚举(当前 111 个取值),
模糊匹配会让「排除房地产」意外命中「房地产服务」与否变得不可预测。
而配置里写错一个不存在的行业名会**静默不排除任何东西** ——
因此 :class:`~hdiv.universe.filters.market.MarketFilter` 在第一次求值时会
拿名单与该表的实际取值核对,写错名字**直接报错**(并提示最接近的候选),
而不是安静地什么都不排除。
"""
#: 要排除的行业名;必须与 ``stock.industry`` 逐字一致
industries: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def _check(self) -> UniverseExclusionsConfig:
seen: set[str] = set()
dup: list[str] = []
for x in self.industries:
if x in seen:
dup.append(x)
seen.add(x)
if dup:
raise SchemaValidationError(
f"universe_exclusions.industries 存在重复项:{dup}"
)
if any(not x.strip() for x in self.industries):
raise SchemaValidationError(
"universe_exclusions.industries 含空字符串;不要就写 []"
)
return self
class BacktestConfig(StrictModel):
version: int = 1
capital: CapitalConfig = Field(default_factory=CapitalConfig)
@@ -571,6 +616,9 @@ class BacktestConfig(StrictModel):
walk_forward: WalkForwardConfig = Field(default_factory=WalkForwardConfig)
daily: DailyConfig = Field(default_factory=DailyConfig)
dividend: DividendHandlingConfig = Field(default_factory=DividendHandlingConfig)
universe_exclusions: UniverseExclusionsConfig = Field(
default_factory=UniverseExclusionsConfig
)
benchmark: list[BenchmarkConfig] = Field(default_factory=list)
risk_free_rate: float = 0.02
fill: FillConfig = Field(default_factory=FillConfig)
@@ -583,6 +631,25 @@ class BacktestConfig(StrictModel):
raise SchemaValidationError(f"benchmark 存在重复代码:{codes}")
return self
def resolved_universe(self, universe: UniverseConfig) -> UniverseConfig:
"""把本文件的行业排除清单**叠加**到筛选配置上,返回新的 UniverseConfig。
- 叠加而非覆盖:``universe.industry_exclusions`` 与
``self.universe_exclusions.industries`` 取并集(去重、保持稳定顺序),
所以本清单只会让股票池变小。
- **不改动传入对象**:UniverseConfig 可能来自 ``lru_cache`` 或调用方复用,
就地修改会污染后续调用(尤其在 walk-forward 的多窗口循环里)。
- 无排除项时原样返回,保证「不配 = 行为与改动前逐字一致」。
"""
merged = list(
dict.fromkeys(
[*universe.industry_exclusions, *self.universe_exclusions.industries]
)
)
if merged == list(universe.industry_exclusions):
return universe
return universe.model_copy(update={"industry_exclusions": merged})
# ---------------------------------------------------------------------------
# report.yml
@@ -773,11 +840,46 @@ class EntryConfig(StrictModel):
return self
class ExitConfirmConfig(StrictModel):
"""卖出前的一致性复核(修复 3b:防「现金流时点」造成的误清仓)。
问题:TTM 每股分红在窗口边界上必然有台阶 —— 一笔分红满 365 天退出,而
下一笔年度分红可能还没**除权**。实测 600690.SH 2026-07-30:FY2025 年度
分红(0.89151)的**实施公告**在 2026-06-25 就已发布(股东大会通过),
只是除权日在 2026-08-21,于是当时可见的「已除权现金」只剩中期 0.26920。
原始 TTM 的「分位 0.1% ≤ P25」在字面上为真,描述的是**现金流时点**,
不是分红能力恶化。
复核规则:触发清仓信号时,若「已公告未除权」的分红(PIT:公告日 ≤ 当日)
说明股息率本应更高,且当前 TTM ÷(TTM + 已公告未除权每股分红)低于
``min_ratio``,则判定为未确认 —— 保持仓位并记录 ``EXIT_UNCONFIRMED``。
真降息(公告金额本身就低)不会命中该规则。
"""
enabled: bool = True
#: 当前 TTM ÷(TTM + 已公告未除权每股分红)的下限;低于它即视为未确认。
#: 0.8 表示「已公告分红足以把股息率抬高 25% 以上」时才拦截。
min_ratio: float = 0.8
#: 只回溯这段时间内公告的分红(自然日)
announce_lookback_days: int = 400
@model_validator(mode="after")
def _check(self) -> ExitConfirmConfig:
if not 0.0 < self.min_ratio <= 1.0:
raise SchemaValidationError(
f"exit.confirm.min_ratio 必须落在 (0, 1],当前 {self.min_ratio}"
)
if self.announce_lookback_days <= 0:
raise SchemaValidationError("exit.confirm.announce_lookback_days 必须为正")
return self
class ExitConfig(StrictModel):
yield_percentile: float
scale_out: list[ScaleStep] = Field(default_factory=list)
stop_loss_pct: float | None = None
max_holding_days: int | None = None
confirm: ExitConfirmConfig = Field(default_factory=ExitConfirmConfig)
@model_validator(mode="after")
def _check(self) -> ExitConfig:
+45
View File
@@ -468,6 +468,51 @@ class Repo:
# 分红(PIT)
# ------------------------------------------------------------------
def announced_dividends(
self, asof: date, *, lookback_days: int = 400
) -> pd.DataFrame:
"""**已公告但尚未除权**的现金分红(卖出复核用,修复 3b)。
与 :meth:`dividend_records` 的 PIT 语义互补:
- ``dividend_records`` 回答「哪些现金**已经**落到股东手里」——
用了 ``imp_ann_date <= asof AND ex_date <= asof``;
- 本方法回答「哪些分红**已经公告**、只是除权日还没到」——
这是当时**确实可知**的信息(``ann_date``/``imp_ann_date`` ≤ asof),
只是还没进入 TTM 现金流口径。
用途:TTM 股息率在窗口边界上会因「上一年度分红到期、本年度还没除权」
而出现台阶(实测 600690.SH 2026-07-30:可见现金只剩 0.26920,而
FY2025 年度 0.89151 早在 2026-06-25 就已实施公告)。若只看已除权现金,
这个台阶会被误读为「分红能力恶化」并触发清仓。
返回列:``symbol / end_date / ann_date / imp_ann_date / cash_div_tax / ex_date``,
仅含 ``cash_div_tax > 0`` 且 ``ex_date > asof`` 的记录。
"""
if not db.table_exists("hd_dividend", self.cfg):
return pd.DataFrame(
columns=["symbol", "end_date", "ann_date", "imp_ann_date",
"cash_div_tax", "ex_date"]
)
since = asof - timedelta(days=int(lookback_days))
sql = """
SELECT symbol, end_date, ann_date, imp_ann_date, div_proc,
cash_div_tax, ex_date, base_share
FROM hd_dividend
WHERE ex_date IS NOT NULL AND ex_date > :asof
AND ann_date IS NOT NULL AND ann_date <= :asof
AND ann_date >= :since
AND cash_div_tax > 0
ORDER BY symbol, ex_date
"""
df = db.read_sql(sql, {"asof": asof, "since": since}, cfg=self.cfg)
for c in ("end_date", "ann_date", "imp_ann_date", "ex_date"):
if c in df.columns and not df.empty:
df[c] = pd.to_datetime(df[c]).dt.date
if "cash_div_tax" in df.columns:
df["cash_div_tax"] = pd.to_numeric(df["cash_div_tax"], errors="coerce")
return df
def dividend_records(
self, asof: date, *, years_back: int = 12, implemented_only: bool = True
) -> pd.DataFrame:
+185 -38
View File
@@ -11,6 +11,11 @@
PIT 纪律:序列上每个日期 t 只使用 ``imp_ann_date <= t`` 且 ``ex_date <= t`` 的分红。
由于 ``ex_date <= t`` 已隐含「已发生」,实现上按 ex_date 归集即可;
``imp_ann_date <= t`` 用于剔除「事后才公告」的记录(极少但存在)。
**经济事件口径**:``hd_dividend`` 写入侧保留全量公告记录,同一 ``(symbol, ex_date)``
可能有不止一条 ``实施`` 记录(全库 1401 组)。任何把 ``cash_div_tax`` 逐行累加的
实现都会把这笔分红重复计入 —— 因此 :func:`ttm_dps_series` 在入口统一调用
:func:`dedupe_dividend_events` 聚合,调用方不需要各自去重。
"""
from __future__ import annotations
@@ -23,6 +28,80 @@ import pandas as pd
# 默认统计窗口(自然日)
TTM_DAYS = 365
#: 聚合同一经济事件时按「逐字段取最大」处理的金额/股数字段
_DIV_AMOUNT_FIELDS = ("cash_div_tax", "cash_div", "stk_div", "stk_bo_rate", "stk_co_rate")
#: 聚合时取**最晚**日期的字段 —— PIT 保守:宁可晚看到,不可早看到
_DIV_DATE_FIELDS = ("ann_date", "imp_ann_date", "end_date", "record_date", "ex_date",
"pay_date")
def dedupe_dividend_events(events: pd.DataFrame) -> pd.DataFrame:
"""把同一 ``(symbol, ex_date)`` 的多条分红记录聚合成**一笔经济事件**。
**为什么必须聚合**:``hd_dividend`` 的写入侧刻意保留预案/股东大会通过/实施的
全量记录(决策 D6),而它的去重键含 ``ann_date``,于是同一笔分红会有多条
``实施`` 记录落在同一个除权日。实测:全库 1401 组(其中 1240 组字段完全相同);
002352.SZ 2024-11-07 同时存在 0.4、1.0、1.4 三条,而 1.4 = 0.4 + 1.0 的合计口径。
查询侧若逐行累加,现金分红会被重复计入、送转股会被重复放大。
聚合口径(即 ``tools/diag_dividend_artifact.py`` 所说的「查询口径按经济事件聚合」):
- **金额/股数逐字段取最大**:既能把「分项 + 合计」收敛到合计(002352.SZ 取 1.4),
又不会像「整行取最大」那样在「一行纯现金 + 一行纯送转」时丢掉送转股;
- **日期取最晚**:每条金额都在最晚公告日之前已经公告,因此不会引入未来函数
(宁可晚看到,不可早看到);
- 其余字段取首次出现的值;缺值按 0 处理(``max`` 跳过 NaN,不会把 0 当成有效
金额覆盖真实值)。
输入已无重复时**原样返回**(热路径短路:``ttm_dps_series`` 会在每只股票上调用)。
"""
if events is None or getattr(events, "empty", True):
return events
keys = [c for c in ("symbol", "ex_date") if c in events.columns]
if "ex_date" not in keys:
return events
if not events.duplicated(subset=keys).any():
return events
df = events.copy()
df["ex_date"] = pd.to_datetime(df["ex_date"], errors="coerce")
df = df[df["ex_date"].notna()]
if df.empty:
return df
for c in _DIV_AMOUNT_FIELDS:
if c in df.columns:
# NaN → 0:纯送转行的 cash_div_tax 是 NULL,不能让 NaN 传染进 TTM 求和
df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0.0)
for c in _DIV_DATE_FIELDS:
if c in df.columns and c != "ex_date":
df[c] = pd.to_datetime(df[c], errors="coerce")
agg = {
c: ("max" if (c in _DIV_AMOUNT_FIELDS or c in _DIV_DATE_FIELDS) else "first")
for c in df.columns
if c not in keys
}
return df.groupby(keys, as_index=False, sort=False).agg(agg)
def _fiscal_year_codes(events: pd.DataFrame, ex: np.ndarray) -> tuple[np.ndarray, bool]:
"""给每笔分红一个可比较的**财年序号**,供同财年接管判定使用。
返回 ``(codes, has_fy)``。``has_fy=False`` 表示事件表没有可用的财年信息,
调用方应退回旧的「按相邻间隔接管」规则(缺信息时不猜)。
有 ``end_date``(分红所属报告期)时用它 —— 这才是「哪个财年的利润分了红」。
但 ``has_fy`` 还要求 ``end_date`` 覆盖 **至少两个不同年份**:只有一个年份时
「跨财年」无从谈起,而单笔事件的短序列(测试夹具、刚上市的股票)会因此
被误加一个 365 天以上的延长窗口。没有该列时退化为「除权日所在年」,且
``has_fy=False``(掉进退化路径)。
"""
if "end_date" in events.columns:
ed = pd.to_datetime(events["end_date"], errors="coerce").dt.year
if ed.notna().any() and ed.dropna().nunique() >= 2:
return ed.fillna(0).to_numpy(dtype="int64"), True
years = pd.to_datetime(events["ex_date"], errors="coerce").dt.year
return years.fillna(0).to_numpy(dtype="int64"), False
def ttm_params() -> tuple[int, int, bool]:
"""读取 TTM 股息率的统一参数 ``(window_days, grace_days, smooth_spikes)``。
@@ -64,6 +143,9 @@ def build_dps_events(dividends: pd.DataFrame) -> dict[str, pd.DataFrame]:
"""按股票整理分红事件(只保留现金分红 > 0)。
返回 ``{symbol: DataFrame[ex_date, imp_ann_date, cash_div_tax]}``,按 ex_date 升序。
这里**不做** ``(symbol, ex_date)`` 聚合 —— 它由 :func:`ttm_dps_series` 在入口
统一处理(同一份口径只需实现一次),诊断脚本可用
``ttm_dps_series(..., dedupe_events=False)`` 复现去重前的毛刺。
"""
if dividends.empty:
return {}
@@ -75,7 +157,6 @@ def build_dps_events(dividends: pd.DataFrame) -> dict[str, pd.DataFrame]:
df = df.dropna(subset=["ex_date"]).sort_values(["symbol", "ex_date"])
return {sym: g.reset_index(drop=True) for sym, g in df.groupby("symbol")}
def ttm_dps_series(
dates: pd.DatetimeIndex,
events: pd.DataFrame,
@@ -83,76 +164,141 @@ def ttm_dps_series(
ttm_days: int = TTM_DAYS,
grace_days: int = 45,
smooth_spikes: bool = True,
dedupe_events: bool = True,
) -> np.ndarray:
"""给定日期序列,向量化计算每一天的 TTM 每股分红。
**毛刺从哪来**:A 股相邻两次除权的间隔经常不是 365 天(实测招商银行
14 次分红中多次落在 355~395 天)。硬 365 天窗口于是在每年除权日附近
制造出两种假象:
口径:**过去 12 个月内已除权的税前现金分红之和**,按 ``(symbol, ex_date)``
聚合成一笔经济事件(见 :func:`dedupe_dividend_events`),并按 ``imp_ann_date``
做 PIT 约束。
**毛刺从哪来**:A 股相邻两次除权的间隔通常不是 365 天(实测招商银行 14 次
分红中多次落在 355~395 天)。硬 365 天窗口于是在每年除权日附近制造两种
日历假象:
- **重叠虚高**:间隔 < 365 天时,新分红入场而旧的尚未到期,两者同时在窗口内。
实测招商银行 2015-07-03:0.620 → 1.290(+108%),10 天后回落到 0.670。
- **断档虚低**:间隔 > 365 天时,旧的已到期而新的尚未入场。
实测中国神华 2016-07-04:0.740 → 0.320(−57%)。
两者都是日历假象而非分红能力变化,却会直接污染「历史分位」这一核心信号
(虚高点拉高分位、虚低点压低 min 与低分位)。
两者都是日历假象而非分红能力变化,却会直接污染「历史分位」这一核心信号。
**修法**:把「硬窗口」换成「按后继接管」。对每次分红 i:
**本轮修复(第二类假象的残余)**:只看**相邻**间隔会漏掉
「上一年度 → 本年度中期 → 本年度年度」这种三年两跳的节奏。实测 600690.SH:
FY2023 年度 2024-08-16、FY2024 年度 2025-07-25、FY2025 中期 2025-11-07、
FY2025 年度 2026-08-21。105 天的间隔让前两笔互不取代,而 392 天又超过
``365 + 45`` —— 2026-07-25 ~ 2026-08-21 出现 28 天空窗,TTM 从 1.23424
掉到 0.26920(−78%),期间公司没有任何真实现金事件。该 run 于是在
2026-07-30 用 2.31% 的假股息率(历史分位 1.57% ≤ P25)误发卖出信号,
次日开盘清仓 —— 实际上不该卖。
- 若与下一次分红的间隔 ``gap >= ttm_days - grace_days``,视为**同一档年度分红**,
计入区间延到 ``min(下一次除权日, 除权日 + ttm_days + grace_days)``:
间隔略小于一年 → 由后继提前接管,**消除重叠虚高**;
间隔略大于一年 → 旧的一直计到新的入场,**填补断档虚低**;
超过 ``ttm_days + grace_days`` 仍无后继(真停发)→ 封顶,如实归零。
- 若 ``gap < ttm_days - grace_days``,视为**年内多次分红**(中期+年度),
彼此不取代,各自保留标准 ``ttm_days`` 窗口 —— 否则会把中期分红误删,
人为制造出新的低点。
- 最后一次分红没有后继:沿用宽限期兜底(与旧行为一致)。
**覆盖窗口算法**(每笔分红 i 的「计入终点」``ends[i]``):
``grace_days`` 现在同时承担两件事:判定「同一档」的容差,以及真停发时的兜底宽度。
1. 基准 = ``ex_date + ttm_days``(关闭 ``smooth_spikes`` 时就是这个值)。
2. **后继接管**(旧行为,阈值 ``ttm_days - grace_days`` = 320 天):相邻间隔
≥ 320 天视为同一档分红 → 旧的计到新的入场(提前交接消除重叠虚高,
或顺延填补一年内的断档虚低),上限 ``ttm_days + grace_days``;
间隔 < 320 天视为年内多次分红 → 各自保留标准窗口。
3. **跨财年补位**(本轮修复):对「本财年最后一笔」,把终点补位到
**下一财年第一笔**的除权日(仍以 ``ttm_days + grace_days`` 封顶)。
这正是消除上面那 28 天空窗的规则;超过宽限期(真断档/停发)则不予补位,
如实归零。
没有 ``end_date`` 时无法识别财年,跳过第 3 步(保守,不猜),第 2 步照常。
``grace_days`` 同时承担两件事:判定「同一档」的容差,与真停发时的兜底宽度。
"""
n = len(dates)
if n == 0:
return np.zeros(0, dtype="float64")
if events.empty:
if dedupe_events:
events = dedupe_dividend_events(events)
if events is None or events.empty:
return np.zeros(n, dtype="float64")
ex = events["ex_date"].to_numpy(dtype="datetime64[ns]")
imp = events["imp_ann_date"].to_numpy(dtype="datetime64[ns]")
dps = events["cash_div_tax"].to_numpy(dtype="float64")
# NaN 感知:送转行的 cash_div_tax 是 NULL,NaN 一旦进入累加会污染整条序列
dps = pd.to_numeric(events["cash_div_tax"], errors="coerce").fillna(0.0).to_numpy(
dtype="float64"
)
d = dates.to_numpy(dtype="datetime64[ns]")
span_strict = np.timedelta64(ttm_days, "D")
span_ext = np.timedelta64(ttm_days + max(0, grace_days), "D")
# 「同一档年度分红」的判定阈值:间隔小于它即视为年内多次分红
# 「同一档年度分红」的判定阈值:相邻间隔小于它即视为年内多次分红
same_slot_min = np.timedelta64(max(0, ttm_days - max(0, grace_days)), "D")
fy, has_fy = _fiscal_year_codes(events, ex)
m = len(ex)
ends = np.array([ex[i] + span_strict for i in range(m)], dtype="datetime64[ns]")
if smooth_spikes:
# (2) 后继接管:同一档分红的时间错位
for i in range(m):
if i + 1 >= m:
continue
if np.isnat(ex[i]) or np.isnat(ex[i + 1]):
continue
if ex[i + 1] - ex[i] >= same_slot_min:
cand = ex[i + 1] if ex[i + 1] < ex[i] + span_ext else ex[i] + span_ext
ends[i] = cand
# (3) 跨财年补位:只看相邻间隔会漏掉「上一年度 → 本年度中期 → 本年度年度」
# 这种跳法(见 docstring 的 600690.SH 实测)。规则:对**每个财年的
# 最后一笔**,把终点补位到**下一财年的最后一笔**入场 —— 空窗正是
# 从本财年末到下一财年最后一笔入场之间的那段。上限仍是
# ``ttm_days + grace_days``:超过宽限期说明是真断档/停发,如实归零。
if has_fy:
for i in range(m):
if np.isnat(ex[i]):
continue
# 本财年最后一笔?
is_last_of_fy = True
for j in range(i + 1, m):
if not np.isnat(ex[j]) and fy[j] == fy[i]:
is_last_of_fy = False
break
if not is_last_of_fy:
continue
# 下一财年的第一笔
nxt = -1
for j in range(i + 1, m):
if not np.isnat(ex[j]) and fy[j] > fy[i]:
nxt = j
break
if nxt < 0:
continue
cap = ex[i] + span_ext
cand = -1
k = nxt
while k < m and not np.isnat(ex[k]) and fy[k] == fy[nxt]:
if ex[k] <= cap and k > cand:
cand = k
k += 1
if cand < 0:
continue # 下一财年整段都在宽限期之外 → 真断档,不补位
if ex[cand] > ends[i]:
ends[i] = ex[cand]
if smooth_spikes:
# (4) 已知的最后一笔分红:宽限期兜底。必须放在 ``if has_fy`` **之外** ——
# 否则单笔事件(has_fy=False)或无下一财年时,上面的 `continue`
# 会把这段整个跳过,窗口在第 365 天就归零。而「单笔事件 + 股价下跌」
# 正是买入闸门测试与刚上市股票的真实形态,不能靠它来兜底。
# 年度分红宣布到除权常隔 12~13 个月,硬 365 天会在下一次除权前
# 制造空窗(正是本 bug)。超过宽限期仍无后继 → 如实归零。
if not np.isnat(ex[m - 1]) and ends[m - 1] < ex[m - 1] + span_ext:
ends[m - 1] = ex[m - 1] + span_ext
acc = np.zeros(n, dtype="float64")
m = len(ex)
for i in range(m):
if np.isnat(ex[i]):
continue
if not smooth_spikes:
end_ts = ex[i] + span_strict
elif i + 1 < m and not np.isnat(ex[i + 1]):
gap = ex[i + 1] - ex[i]
if gap >= same_slot_min:
# 同一档:由后继接管,但不超过宽限期封顶
end_ts = min(ex[i + 1], ex[i] + span_ext)
else:
# 年内多次分红:保留标准窗口,互不取代
end_ts = ex[i] + span_strict
else:
# 最后一次分红:宽限期兜底
end_ts = ex[i] + span_ext
start = int(np.searchsorted(d, ex[i], side="left"))
if not np.isnat(imp[i]):
# PIT:公告日之前不可见
start = max(start, int(np.searchsorted(d, imp[i], side="left")))
end = int(np.searchsorted(d, end_ts, side="left"))
end = int(np.searchsorted(d, ends[i], side="left"))
if end > start:
acc[start:end] += dps[i]
return acc
@@ -165,6 +311,7 @@ def dividend_yield_series(
ttm_days: int = TTM_DAYS,
grace_days: int = 45,
smooth_spikes: bool = True,
dedupe_events: bool = True,
) -> pd.DataFrame:
"""构造单只股票的股息率日序列。
@@ -182,7 +329,7 @@ def dividend_yield_series(
return pd.DataFrame(columns=["trade_date", "close", "ttm_dps", "dividend_yield"])
idx = pd.DatetimeIndex(pd.to_datetime(s.index))
dps = ttm_dps_series(idx, events, ttm_days=ttm_days, grace_days=grace_days,
smooth_spikes=smooth_spikes)
smooth_spikes=smooth_spikes, dedupe_events=dedupe_events)
out = pd.DataFrame({"trade_date": idx, "close": s.to_numpy(dtype="float64"), "ttm_dps": dps})
out["dividend_yield"] = out["ttm_dps"] / out["close"]
return out.reset_index(drop=True)
+1 -1
View File
@@ -16,7 +16,7 @@ from hdiv.report.renderer import Provenance, Renderer, query
# 滤网中文说明(呈现用,非业务逻辑)
_FILTER_DESC = {
"market": "交易所 / 板块 / 上市年限 / 市值 / 流动性 / 当日可交易",
"market": "行业排除清单 / 交易所 / 板块 / 上市年限 / 市值 / 流动性 / 当日可交易",
"risk": "ST / 退市 / 停牌 / 净资产为负 / 资产负债率(金融豁免)",
"dividend": "股息率 / 连续分红年数 / 窗口内分红次数 / 支付率 / FCF 覆盖",
"quality": "年均 ROE / ROIC / 毛利率 / 净利率 / 经营现金流对利润(金融豁免)",
+6 -5
View File
@@ -110,11 +110,12 @@ class DailyUniverseScreener:
self.prune = PruneReport()
self._allowed: set[str] | None = None
@classmethod
def from_strategy(cls, registry: Any, strategy: Any, repo: PitRepo,
*, verbose: bool = True) -> DailyUniverseScreener:
cfg = registry.resolved_universe(strategy)
return cls(cfg, repo, verbose=verbose)
# 刻意**没有** ``from_strategy(registry, strategy, ...)``:
# ``registry.resolved_universe`` 只含策略 + universe.yml,拿不到
# backtest.yml 的 ``universe_exclusions``(行业黑名单)。保留这样一个
# 便捷构造器,等于给「逐日选股绕过行业排除」留了一条谁都看不出来的路。
# 调用方应先用 ``BacktestConfig.resolved_universe`` 合并出**生效后**的
# UniverseConfig(见 DailyRunner.__init__),再用普通构造函数传入。
# ------------------------------------------------------------------
# 预剪枝
+26 -4
View File
@@ -6,7 +6,10 @@
- 「分红年度」以**报告期年份**(``end_date.year``)归属 ——
这符合「哪个财年的利润分了红」的通常理解;
- 连续性只要求到「最近一个**年报已公告**的财年」为止,
避免在年报尚未披露时误判为中断。
避免在年报尚未披露时误判为中断;
- 同一 ``(symbol, ex_date)`` 的多条记录按**一笔经济事件**聚合
(与 ``ttm_dps_series`` 共用 ``dedupe_dividend_events``),
否则年度 DPS 与总现金分红会被重复累加。
PIT 纪律:只使用 ``imp_ann_date <= asof`` 且 ``ex_date <= asof`` 的记录。
"""
@@ -19,13 +22,28 @@ from typing import Any
import pandas as pd
from hdiv.core.config import DividendFilterConfig
from hdiv.factor.dividend_yield import ttm_dps_at
from hdiv.factor.dividend_yield import dedupe_dividend_events, ttm_dps_at
from hdiv.universe.filters.base import Filter, FilterOutcome
# 年报到次年 4 月 30 日前披露完毕(法定上限)
ANNUAL_REPORT_DEADLINE_MONTH_DAY = (4, 30)
def _dedupe_cash_records(cash: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按 ``(symbol, ex_date)`` 把同一笔分红的多条记录收敛成一条。
年度 DPS(→ CAGR / 波动率)与「总现金分红」(→ 支付率 / FCF 覆盖)都是
**逐行累加** ``cash_div_tax``,重复记录会让分子虚高、让 FCF 覆盖虚低。
聚合口径与 ``ttm_dps_series`` 完全一致(共用
:func:`~hdiv.factor.dividend_yield.dedupe_dividend_events`),避免同一份
分红在「股息率」与「支付率」两个指标上口径漂移。
"""
if len(cash) < 2:
return cash
df = dedupe_dividend_events(pd.DataFrame(cash))
return df.to_dict("records")
class DividendFilter(Filter):
name = "dividend"
@@ -146,7 +164,9 @@ class DividendFilter(Filter):
recs: list[dict[str, Any]], target_year: int, asof: date, cfg: DividendFilterConfig
) -> dict[str, Any]:
"""计算分红连续性与 TTM 股息。"""
cash = [r for r in recs if (r.get("cash_div_tax") or 0) > 0]
cash = _dedupe_cash_records(
[r for r in recs if (r.get("cash_div_tax") or 0) > 0]
)
years: set[int] = set()
for r in cash:
ed = r.get("end_date")
@@ -246,7 +266,9 @@ class DividendFilter(Filter):
分子(去年分红)与分母(今年一季度利润)不同期,结果无意义。
"""
out: dict[str, Any] = {"payout_ratio": None, "fcf_dividend_cover": None}
cash = [r for r in recs if (r.get("cash_div_tax") or 0) > 0]
cash = _dedupe_cash_records(
[r for r in recs if (r.get("cash_div_tax") or 0) > 0]
)
if not cash:
return out
years = sorted(
+79 -2
View File
@@ -1,6 +1,6 @@
"""MarketFilter —— 市场属性过滤(plan.md §5.1)。
条件:交易所 / 板块 / 上市年限 / 总市值 / 流通市值 / 流动性 / 当日可交易。
条件:行业排除 / 交易所 / 板块 / 上市年限 / 总市值 / 流通市值 / 流动性 / 当日可交易。
"""
from __future__ import annotations
@@ -11,15 +11,78 @@ from typing import Any
import pandas as pd
from hdiv.core.config import MarketFilterConfig
from hdiv.core.errors import ConfigError
from hdiv.universe.filters.base import Filter, FilterOutcome
class MarketFilter(Filter):
name = "market"
def __init__(self, config: MarketFilterConfig) -> None:
def __init__(
self,
config: MarketFilterConfig,
*,
exclude_industries: list[str] | None = None,
) -> None:
super().__init__(config)
self.cfg = config
#: 行业黑名单(来自 ``UniverseConfig.industry_exclusions``,
#: 回测时由 backtest.yml 的 universe_exclusions 叠加进来)。
#: 空列表 = 不排除任何行业。
self.exclude_industries: list[str] = list(exclude_industries or ())
#: 名单是否已与 stock 表核对过(每个实例只查一次)
self._industries_checked = False
# ------------------------------------------------------------------
# 名单自检
# ------------------------------------------------------------------
def _check_known_industries(self, repo: Any) -> None:
"""确认黑名单里的每个行业名都真实存在。
**为什么必须查一次**:``stock.industry`` 是有限枚举,而「房地产业」
这种听起来正确、库里却**根本不存在**的名字不会有任何一行命中 ——
股票池看起来「排除了」,实际一只没少,属于最难发现的静默失效
(本项目已有同类记录:配置写了但没有任何代码路径会读)。
核实后写错名字会**直接报错**并提示最接近的候选。
"""
if self._industries_checked or not self.exclude_industries:
return
self._industries_checked = True
master = repo.stock_master()
if master is None or "industry" not in getattr(master, "columns", []):
return
known = {str(x) for x in master["industry"].dropna().unique()}
unknown = [x for x in self.exclude_industries if x not in known]
if not unknown:
return
# 提示最接近的真实取值。刻意用**字符重合度**而不是前缀匹配:
# 用户最可能的错法(写「房地产业」,库里只有「全国地产/区域地产」)
# 与任何真实取值都没有共同前缀,前缀匹配给不出任何提示。
hints: list[str] = []
for name in unknown:
chars = set(name)
scored = sorted(
((len(chars & set(k)), len(k), k) for k in known),
key=lambda t: (-t[0], t[1]),
)
near = [k for sc, _n, k in scored if sc >= 2][:5]
if near:
hints.append(f" {name} → 是否想写:{'、'.join(near)}")
detail = ("\n" + "\n".join(hints)) if hints else ""
raise ConfigError(
f"排除行业清单含数据库里不存在的行业名:{unknown}。\n"
f" stock.industry 是精确枚举,写错名字**不会排除任何股票**,"
f"因此这里直接报错。{detail}\n"
f" 查当前全部取值:\n"
f" SELECT industry, COUNT(*) FROM stock GROUP BY industry ORDER BY 2 DESC;\n"
f" 注意「房地产业」并不在库里,它被拆成 全国地产 / 区域地产 / "
f"房产服务 / 园区开发 四个值。"
)
# ------------------------------------------------------------------
# 求值
# ------------------------------------------------------------------
def compute(self, candidates: pd.DataFrame, repo: Any, asof: date) -> FilterOutcome:
cfg = self.cfg
@@ -32,10 +95,24 @@ class MarketFilter(Filter):
mask.at[idx] = False
reasons[df.at[idx, "symbol"]] = why
# --- 行业排除(黑名单)---
# 放在最前:它是纯名称匹配,不需要任何数值解析;而且「这个行业不做」
# 是最强的排除理由 —— 若排在市值/流动性之后,被排除的股票会先以
# 「市值不足」等理由落选,事后无法分辨「行业被排除了」还是「真的不达标」。
if self.exclude_industries:
self._check_known_industries(repo)
banned = set(self.exclude_industries)
for idx in df.index:
ind = df.at[idx, "industry"] if "industry" in df.columns else None
if isinstance(ind, str) and ind in banned:
fail(idx, f"行业「{ind}」在排除清单中")
# --- 交易所 ---
if cfg.exchanges:
allowed = set(cfg.exchanges)
for idx in df.index:
if not mask.at[idx]:
continue
if df.at[idx, "exchange"] not in allowed:
fail(idx, f"交易所 {df.at[idx, 'exchange']} 不在 {sorted(allowed)}")
+3 -1
View File
@@ -79,7 +79,9 @@ class UniverseSelector:
c = self.config
exempt_leverage = list(c.industry_exemptions.leverage)
self._filters = {
"market": MarketFilter(c.market),
"market": MarketFilter(
c.market, exclude_industries=list(c.industry_exclusions)
),
"risk": RiskFilter(c.risk, exempt_leverage=exempt_leverage),
"dividend": DividendFilter(c.dividend),
"quality": FinancialQualityFilter(
+4 -1
View File
@@ -299,7 +299,10 @@ def _funnel(run_id: str, candidate_count: int, member_count: int,
因此这里把「候选 → market → risk → dividend → quality → 入选」
的存活曲线算好返回,前端只渲染不算数。
"""
labels = ["候选", "市场属性", "风险", "分红", "财务质量"]
# 「market」滤网含**行业排除清单**(backtest.yml 的 universe_exclusions
# 会叠加进筛选配置),所以标签里带上「行业」,否则被排除的地产股会
# 无声地算进「市场属性」这一段,读者看不出是行业原因。
labels = ["候选", "行业/市场属性", "风险", "分红", "财务质量"]
order = ["market", "risk", "dividend", "quality"]
values = [candidate_count]
cur = candidate_count