说明:本提交是工作区中此前的未提交工作(在 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` 无界扫描仍然很慢)。
217 lines
8.8 KiB
Python
217 lines
8.8 KiB
Python
"""股息率(TTM 每股分红)毛刺诊断脚本。
|
||
|
||
**用途**:定位「数据毛刺导致回测异常成交」这一类问题。
|
||
它不改动任何数据,只读库、只打印。
|
||
|
||
用法::
|
||
|
||
export PYTHONPATH=src
|
||
.venv/bin/python tools/diag_dividend_artifact.py --symbol 600690.SH \
|
||
--start 2026-07-01 --end 2026-08-05
|
||
|
||
诊断三件事:
|
||
|
||
1. **同一除权日的重复分红记录**:``hd_dividend`` 写入侧刻意保留
|
||
预案/股东大会通过/实施 全量记录(决策 D6),同一 ``(symbol, ex_date)``
|
||
因此可能有多条 ``实施`` 记录。业务路径已由
|
||
``factor.dividend_yield.dedupe_dividend_events`` 按经济事件聚合
|
||
(``ttm_dps_series`` 入口统一调用),本脚本用 ``dedupe_events=False``
|
||
复现「逐行累加」的旧口径,用来量化这个毛刺曾经有多大。
|
||
2. **TTM 每股分红的时间线**:逐交易日打印去重前 / 去重后的取值,
|
||
毛刺会表现为「无任何真实现金事件的一天突然跳变」。
|
||
3. **异常成交反查**:给定回测 run_id,列出每一笔卖出当日 TTM 值的
|
||
去重前/去重后差异,标出「去重后不再触发卖出」的笔数。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import sys
|
||
from datetime import date, timedelta
|
||
from pathlib import Path
|
||
|
||
import pandas as pd
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||
|
||
from hdiv.data import db # noqa: E402
|
||
from hdiv.factor.dividend_yield import ( # noqa: E402
|
||
build_dps_events,
|
||
dedupe_dividend_events,
|
||
ttm_dps_series,
|
||
ttm_params,
|
||
)
|
||
|
||
|
||
def diagnose_symbol(symbol: str, start: date, end: date) -> None:
|
||
div = db.read_sql(
|
||
"SELECT symbol, end_date, ann_date, imp_ann_date, div_proc, "
|
||
" cash_div_tax, ex_date "
|
||
"FROM hd_dividend WHERE symbol = :s AND cash_div_tax > 0 "
|
||
" AND ex_date IS NOT NULL AND div_proc = '实施' "
|
||
"ORDER BY ex_date",
|
||
{"s": symbol},
|
||
)
|
||
events = build_dps_events(div).get(symbol)
|
||
if events is None or events.empty:
|
||
print(f"{symbol}: 无已实施现金分红记录")
|
||
return
|
||
|
||
dups = events.groupby("ex_date").size()
|
||
dups = dups[dups > 1]
|
||
print(f"=== {symbol} 分红记录 ===")
|
||
print(f"已实施现金分红行数:{len(events)},唯一除权日:{events['ex_date'].nunique()}")
|
||
print(f"**同一除权日重复 {len(dups)} 组**(这些金额被重复累加):")
|
||
for ex, n in dups.items():
|
||
rows = events[events["ex_date"] == ex]
|
||
print(
|
||
f" {ex.date()} x{n} 金额={rows['cash_div_tax'].tolist()} "
|
||
f"实施日={[str(pd.Timestamp(x).date()) for x in rows['imp_ann_date']]}"
|
||
)
|
||
|
||
px = db.read_sql(
|
||
"SELECT trade_date, close FROM stock_daily "
|
||
"WHERE symbol = :s AND trade_date BETWEEN :a AND :b ORDER BY trade_date",
|
||
{"s": symbol, "a": start, "b": end},
|
||
)
|
||
if px.empty:
|
||
print("区间内无行情")
|
||
return
|
||
px["trade_date"] = pd.to_datetime(px["trade_date"])
|
||
idx = pd.DatetimeIndex(px["trade_date"])
|
||
w, g, sm = ttm_params()
|
||
# 「去重前」必须显式关掉入口聚合,否则业务路径(默认去重)永远看不到毛刺
|
||
raw = ttm_dps_series(idx, events, ttm_days=w, grace_days=g, smooth_spikes=sm,
|
||
dedupe_events=False)
|
||
ded = ttm_dps_series(idx, events, ttm_days=w, grace_days=g, smooth_spikes=sm)
|
||
out = pd.DataFrame(
|
||
{
|
||
"trade_date": px["trade_date"].dt.date,
|
||
"close": px["close"],
|
||
"ttm_dps(去重前)": raw,
|
||
"ttm_dps(去重后)": ded,
|
||
}
|
||
)
|
||
out["股息率%(去重前)"] = (out["ttm_dps(去重前)"] / out["close"] * 100).round(3)
|
||
out["股息率%(去重后)"] = (out["ttm_dps(去重后)"] / out["close"] * 100).round(3)
|
||
print("\n=== 逐交易日 TTM 每股分红 ===")
|
||
print(out.to_string(index=False))
|
||
|
||
jump = out[pd.Series(raw, index=range(len(out))).diff().abs() > 1e-9]
|
||
print("\n出现跳变的交易日(应与真实分红除权日一一对应):")
|
||
print(jump[["trade_date", "ttm_dps(去重前)", "ttm_dps(去重后)"]].to_string(index=False))
|
||
|
||
|
||
def diagnose_run(run_id: str) -> None:
|
||
"""反查某次回测的每一笔卖出:去重后是否仍会触发。"""
|
||
from hdiv.data.repo import Repo
|
||
|
||
repo = Repo()
|
||
trades = db.read_sql(
|
||
"SELECT symbol, signal_date, execution_date, reason_json "
|
||
"FROM hd_backtest_trade WHERE run_id = :r AND side = 'SELL' "
|
||
"ORDER BY execution_date",
|
||
{"r": run_id},
|
||
)
|
||
if trades.empty:
|
||
print("该 run 无卖出成交")
|
||
return
|
||
w, g, sm = ttm_params()
|
||
years = 5
|
||
rows = []
|
||
for _, t in trades.iterrows():
|
||
sym = t["symbol"]
|
||
d0 = pd.Timestamp(t["signal_date"]).date()
|
||
div = repo.dividend_records(d0, years_back=years + 3)
|
||
div = div[div["symbol"] == sym]
|
||
ev = build_dps_events(div).get(sym)
|
||
if ev is None or ev.empty:
|
||
continue
|
||
px = repo.price_history([sym], date(d0.year - years - 1, 1, 1), d0, adjust="none")
|
||
px = px.sort_values("trade_date")
|
||
px["trade_date"] = pd.to_datetime(px["trade_date"])
|
||
s = px.set_index("trade_date")["close"]
|
||
i = pd.DatetimeIndex(s.index)
|
||
ref = (d0 - timedelta(days=int(365.25 * years)), d0)
|
||
|
||
def decision(events: pd.DataFrame, *, dedupe: bool) -> tuple[float, float, float]:
|
||
dps = pd.Series(
|
||
ttm_dps_series(i, events, ttm_days=w, grace_days=g, smooth_spikes=sm,
|
||
dedupe_events=dedupe),
|
||
index=i,
|
||
)
|
||
y = (dps / s).loc[: pd.Timestamp(d0)]
|
||
cur = float(y.iloc[-1])
|
||
rs = y.loc[pd.Timestamp(ref[0]) : pd.Timestamp(ref[1])]
|
||
pct = float((rs <= cur).sum() / rs.size * 100) if rs.size else float("nan")
|
||
return cur, pct, float(rs.quantile(0.25)) if rs.size else float("nan")
|
||
|
||
for label, dedupe in (("去重前", False), ("去重后", True)):
|
||
cur, pct, p25 = decision(ev, dedupe=dedupe)
|
||
rows.append(
|
||
{
|
||
"symbol": sym,
|
||
"signal_date": d0,
|
||
"口径": label,
|
||
"股息率%": round(cur * 100, 3),
|
||
"分位%": round(pct, 2),
|
||
"P25%": round(p25 * 100, 3),
|
||
"触发卖出(P25)": pct <= 25.0,
|
||
}
|
||
)
|
||
if not rows:
|
||
return
|
||
df = pd.DataFrame(rows)
|
||
print(f"\n=== run {run_id} 卖出决策:去重前 vs 去重后 ===")
|
||
print(df.pivot_table(
|
||
index=["symbol", "signal_date"], columns="口径",
|
||
values=["分位%", "触发卖出(P25)"], aggfunc="first",
|
||
).to_string())
|
||
before = df[df["口径"] == "去重前"].set_index(["symbol", "signal_date"])["触发卖出(P25)"]
|
||
after = df[df["口径"] == "去重后"].set_index(["symbol", "signal_date"])["触发卖出(P25)"]
|
||
flipped = before[before & ~after.reindex(before.index).fillna(False)]
|
||
print(f"\n去重后**不再触发**卖出的成交:{len(flipped)} / {len(before)} 笔")
|
||
for k in flipped.index:
|
||
print(f" {k[0]} 信号日 {k[1]}")
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="股息率毛刺诊断")
|
||
ap.add_argument("--symbol", help="股票代码,如 600690.SH")
|
||
ap.add_argument("--start", help="起始日 YYYY-MM-DD")
|
||
ap.add_argument("--end", help="结束日 YYYY-MM-DD")
|
||
ap.add_argument("--run-id", help="反查某次回测的所有卖出成交")
|
||
ap.add_argument("--duplicate-survey", action="store_true",
|
||
help="全库统计:同一除权日重复记录的股票数")
|
||
args = ap.parse_args()
|
||
|
||
db.load_dotenv_once()
|
||
|
||
if args.duplicate_survey:
|
||
df = db.read_sql(
|
||
"SELECT symbol, COUNT(*) AS rows_, COUNT(DISTINCT ex_date) AS ex_dates "
|
||
"FROM hd_dividend WHERE cash_div_tax > 0 AND ex_date IS NOT NULL "
|
||
" AND div_proc = '实施' GROUP BY symbol",
|
||
{},
|
||
)
|
||
bad = df[df["rows_"] > df["ex_dates"]]
|
||
print(f"有已实施现金分红的股票:{len(df)}")
|
||
print(f"**存在同一除权日重复记录的股票:{len(bad)}**"
|
||
f"(多出 {int((bad['rows_'] - bad['ex_dates']).sum())} 行被重复累加)")
|
||
print(bad.sort_values("rows_", ascending=False).head(20).to_string(index=False))
|
||
return 0
|
||
|
||
if args.run_id:
|
||
diagnose_run(args.run_id)
|
||
if args.symbol:
|
||
start = date.fromisoformat(args.start) if args.start else date(2026, 1, 1)
|
||
end = date.fromisoformat(args.end) if args.end else date(2026, 12, 31)
|
||
diagnose_symbol(args.symbol, start, end)
|
||
if not (args.run_id or args.symbol or args.duplicate_survey):
|
||
ap.print_help()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|