Files
ggx/config/backtest.yml
T
simon fdfdd152d8 修复: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` 无界扫描仍然很慢)。
2026-10-05 16:19:07 +08:00

161 lines
8.0 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ============================================================
# 回测配置(★ 可直接修改)
# 对应 plan.md §22–§27(Walk-forward / 过拟合控制)与 §29–§31
# ============================================================
version: 1
capital:
initial: 1000000 # 元
currency: CNY
period:
# P0b 已完成 2015-2018 行情回补,行情/每日指标均覆盖 2015-01-05 起,
# 因此按 plan.md §22「历史数据 10 年」的要求使用完整窗口。
start: 2015-01-01
end: latest
# ------------------------------------------------------------
# 排除行业清单(股票池黑名单)
#
# 语义:按 stock.industry **精确匹配**(区分字面,不做前缀/模糊匹配),
# 命中的股票在股票池阶段就被淘汰,三种回测模式
# (single / walkforward / daily)一律生效。
#
# 与 config/universe.yml 的关系是**叠加**,不是覆盖:
# universe.yml —— 「什么样的公司够格」(市值/分红/质量 + 行业豁免)
# 本清单 —— 「这次研究特意不要哪些行业」
# 因此本清单只会让股票池变小,不会放宽任何既有条件。
# 若 universe.yml 自己也声明了 industry_exclusions,两者取并集。
#
# 行业名必须与数据库 stock.industry 逐字一致。查当前取值:
# SELECT industry, COUNT(*) FROM stock GROUP BY industry ORDER BY 2 DESC;
# 留空 [] = 不排除任何行业。
# ------------------------------------------------------------
universe_exclusions:
# 房地产业。注意:数据库里**没有**「房地产业」这个标签,它被拆成四个行业名,
# 所以这里要写全四条,只写「房地产」不会有任何匹配:
# 全国地产 / 区域地产 —— 房地产开发(万科A、保利发展、金地集团…)
# 房产服务 —— 物业/中介/房产服务(招商积余、我爱我家…)
# 园区开发 —— 开发区与园区运营商(陆家嘴、张江高科…)
industries: [全国地产, 区域地产, 房产服务, 园区开发]
# ------------------------------------------------------------
# 调度:多久评估一次信号、多久重建一次股票池
# ------------------------------------------------------------
schedule:
# 信号评估频率(月):1 = 每月末评估一次买卖信号
signal_frequency_months: 1
# 股票池重建频率(月):高股息池成员(大市值/长上市/连续分红)非常稳定,
# 每年重建一次即可;调小更严格但显著更慢。
universe_refresh_months: 12
# 信号与成交的时间关系由 fill.price 决定(next_open = 次日开盘成交)
# ------------------------------------------------------------
# 分位阈值口径
# ------------------------------------------------------------
percentile_reference:
# rolling —— 用截至评估日的滚动窗口分布(PIT 自适应,单次回测默认)
# frozen —— 冻结训练窗口分布(walk-forward 测试段必须用这个,plan.md §25)
mode: rolling
# rolling 模式下的回看年数
lookback_years: 5
# ------------------------------------------------------------
# Walk-forward(plan.md §23/§24/§25)
# ------------------------------------------------------------
walk_forward:
enabled: true
# rolling —— 固定长度训练窗(plan.md §23 的示例)
# expanding —— 训练窗递增
scheme: rolling
train_years: 5 # plan.md §43: Training = 5~8 年
test_years: 1 # plan.md §43: Test = 1~2 年
step_months: 12 # 窗口步进
min_train_years: 5
# 测试期禁止重新调参:引擎层硬约束,train 段产出的参数对象冻结后传入
freeze_params_in_test: true
# ------------------------------------------------------------
# 每日动态股票池(backtest --mode daily)
#
# 与上面的 walk_forward 是**两种不同的检验**,不要混用:
# walk_forward —— 切多个 (train, test) 窗口检验过拟合(样本外能否复现);
# daily —— 从 --start 起跑**一条连续路径**,每个交易日重新选股、
# 每个交易日判断买卖,回答「动态股票池下实际会怎样」。
# daily 模式不使用训练段,也就没有「冻结分布」——阈值口径一律 rolling(PIT)。
# ------------------------------------------------------------
daily:
# 股票池重建频率(交易日):1 = 每个交易日按当日可见数据重新筛选
universe_refresh_days: 1
# 信号评估频率(交易日):1 = 每个交易日评估买卖点
signal_frequency_days: 1
# 持仓掉出当日股票池后怎么办:
# hold —— 只减不加(默认):不再买入/加仓,但不因「掉出池子」而清仓,
# 仍按股息率分位规则决定减仓/卖出。池子回答「能买什么」,
# 不回答「必须卖什么」。
# sell —— 掉出即视为卖出信号,次日开盘清仓。
pool_exit_action: hold
# 买卖决策发生时计算并留痕个股画像(**不区分是否在当日池内**)。
# 卖出/减仓同样触发画像 —— 否则「为什么卖」缺证据。
profile_on_trade: true
# 是否把每日入选成员写入 hd_daily_universe(供事后查「某天为什么是这些股票」)
persist_daily_universe: true
# 批量预载的分块年数(内存控制):行情/每日指标按年分块载入,用完即弃
chunk_years: 1
# 逐日选股的进度打印间隔(交易日)
progress_every_days: 20
# ------------------------------------------------------------
# 分红处理(plan.md §30/§31)
# ------------------------------------------------------------
dividend:
# reinvest —— 现金分红**回落到可投资现金池**:与初始资金同一个 cash 变量,
# 下次调仓时按目标权重再配置(默认;plan.md §31 模式 B 的权重口径)
# hold —— 分红永久留存、不参与后续买入(未实现,会写入 unimplemented)
# cash_out —— 分红移出组合(未实现,会写入 unimplemented)
cash_mode: reinvest
# portfolio_rebalance —— 下次调仓时按目标权重再配置(已实现,默认)
# same_stock_next_open —— 按同一只股票次日开盘再投资(未实现,会写入 unimplemented)
reinvest_rule: portfolio_rebalance
# 红利税**总闸**;分档税率在 config/cost.yml 的 dividend_tax
# (两者必须同时为真才计税)
apply_dividend_tax: true
# 送股/转增:按 stk_div 调整股数、总成本不变(plan.md §30)
# false 时股数不调整,而不复权价照常除权下跌 → 会写入 unimplemented
handle_stock_dividend: true
# 配股:**未实现**(无配股价/比例数据)。true = 配置声称要处理,
# 每次 run 都会在 unimplemented_json 里声明这个缺口
handle_rights_issue: true
# ------------------------------------------------------------
# 基准(plan.md §28.4)
# ------------------------------------------------------------
benchmark:
- { code: 000300.SH, name: 沪深300 }
- { code: 000922.CSI, name: 中证红利 }
- { code: 000001.SH, name: 上证指数 }
# 无风险利率(年化),用于 Sharpe / Sortino
risk_free_rate: 0.02
# ------------------------------------------------------------
# 成交撮合
# ------------------------------------------------------------
fill:
# next_open —— 信号次日开盘成交(默认,最贴近实盘)
# next_close / same_close
price: next_open
partial_fill: false
# 单笔成交不超过当日成交量的比例(流动性约束)
max_volume_pct: 0.05
# 涨跌停处理:skip(跳过当日)/ defer(顺延到下一可成交日)
limit_up_down_rule: skip
suspended_rule: defer
# ------------------------------------------------------------
# 可复现性(plan.md §47 原则 6)
# ------------------------------------------------------------
reproducibility:
record_code_version: true
record_data_version: true
seed: 42