修复: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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,7 +16,7 @@ from hdiv.report.renderer import Provenance, Renderer, query
|
||||
|
||||
# 滤网中文说明(呈现用,非业务逻辑)
|
||||
_FILTER_DESC = {
|
||||
"market": "交易所 / 板块 / 上市年限 / 市值 / 流动性 / 当日可交易",
|
||||
"market": "行业排除清单 / 交易所 / 板块 / 上市年限 / 市值 / 流动性 / 当日可交易",
|
||||
"risk": "ST / 退市 / 停牌 / 净资产为负 / 资产负债率(金融豁免)",
|
||||
"dividend": "股息率 / 连续分红年数 / 窗口内分红次数 / 支付率 / FCF 覆盖",
|
||||
"quality": "年均 ROE / ROIC / 毛利率 / 净利率 / 经营现金流对利润(金融豁免)",
|
||||
|
||||
@@ -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__),再用普通构造函数传入。
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 预剪枝
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user