说明:本提交是工作区中此前的未提交工作(在 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` 无界扫描仍然很慢)。
349 lines
14 KiB
Python
349 lines
14 KiB
Python
"""每日动态股票池筛选器(``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)
|