Files
ggx/tests/test_backtest.py
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

1003 lines
42 KiB
Python
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.
"""回测引擎、成本、分红、绩效与敏感性测试。
覆盖的都是「错了也不会报错、只会静默给出错误结论」的地方:
- 总市值漏掉现金 → 净值曲线失真;
- 会计恒等式不平 → 成本/分红有遗漏;
- 分位阈值口径混用 → 未来函数;
- 分批建仓与减仓互相冲突 → 高频无效交易;
- 参数耦合未同步 → 扫出的差异来自形状畸变而非阈值本身。
"""
from __future__ import annotations
from datetime import date, timedelta
import numpy as np
import pandas as pd
import pytest
from hdiv.backtest.engine import (
CostModel,
Position,
Signal,
_months_between,
_round_lot,
build_yield_series,
dedupe_dividend_events,
dividend_handling_notes,
reconcile,
)
from hdiv.backtest.walk_forward import WalkForwardRunner, _add_months, _add_years
from hdiv.core.config import CostConfig, load_config
from hdiv.strategy.registry import StrategyRegistry, apply_sweep, parse_sweep
# ---------------------------------------------------------------------------
# 成本模型
# ---------------------------------------------------------------------------
@pytest.fixture
def cost() -> CostModel:
return CostModel(load_config("cost"))
def test_commission_has_minimum(cost: CostModel) -> None:
"""小额成交必须触发最低佣金 5 元。"""
c, s, t = cost.fees(1000.0, "BUY")
assert c == pytest.approx(5.0), "1000 × 0.025% = 0.25 元,应被最低佣金托底"
assert s == 0.0, "买入不收印花税"
assert t == pytest.approx(1000.0 * 0.00001)
def test_large_commission_uses_rate(cost: CostModel) -> None:
c, _, _ = cost.fees(1_000_000.0, "BUY")
assert c == pytest.approx(250.0)
def test_stamp_duty_sell_only(cost: CostModel) -> None:
_, s_buy, _ = cost.fees(1_000_000.0, "BUY")
_, s_sell, _ = cost.fees(1_000_000.0, "SELL")
assert s_buy == 0.0
assert s_sell == pytest.approx(500.0)
def test_slippage_direction(cost: CostModel) -> None:
"""买入价上滑、卖出价下滑 —— 方向反了会凭空产生收益。"""
assert cost.slip(100.0, "BUY") > 100.0
assert cost.slip(100.0, "SELL") < 100.0
assert cost.slip(100.0, "BUY") == pytest.approx(100.1) # 10bps
def test_slippage_modes() -> None:
cfg = CostConfig.model_validate(
{"version": 1, "slippage": {"mode": "fixed", "value": 0.02}}
)
assert CostModel(cfg).slip(100.0, "BUY") == pytest.approx(100.02)
cfg2 = CostConfig.model_validate(
{"version": 1, "slippage": {"mode": "tick", "value": 2}}
)
assert CostModel(cfg2).slip(100.0, "BUY") == pytest.approx(100.02)
def test_dividend_tax_by_holding_period(cost: CostModel) -> None:
"""plan.md §30:持股越久税率越低,超过 1 年免税。"""
assert cost.dividend_tax_rate(10) == pytest.approx(0.20)
assert cost.dividend_tax_rate(100) == pytest.approx(0.10)
assert cost.dividend_tax_rate(400) == pytest.approx(0.00)
# ---------------------------------------------------------------------------
# A 股交易规则
# ---------------------------------------------------------------------------
def test_round_lot_is_100_shares() -> None:
assert _round_lot(150) == 100
assert _round_lot(99) == 0
assert _round_lot(1000) == 1000
assert _round_lot(-5) == 0
def test_months_between() -> None:
assert _months_between(date(2024, 1, 15), date(2024, 1, 30)) == 0
assert _months_between(date(2024, 1, 15), date(2024, 2, 1)) == 1
assert _months_between(date(2023, 12, 1), date(2024, 12, 1)) == 12
# ---------------------------------------------------------------------------
# 资金对账(P4 硬验收)
# ---------------------------------------------------------------------------
class _T:
def __init__(self, side: str, amount: float, fee: float) -> None:
self.side = side
self.amount = amount
self.total_cost = fee
def test_reconcile_balanced() -> None:
eq = pd.DataFrame({"cash": [1000.0, 300.0, 550.0]})
trades = [_T("BUY", 700.0, 5.0), _T("SELL", 300.0, 3.0)]
# 1000 - 700 - 5 - 3 + 300 + 分红 0 = 592?
# 实际:1000 - 700 - 5(买佣) - 3(卖佣) + 300 = 592;现金应为 592
eq = pd.DataFrame({"cash": [592.0]})
rc = reconcile(eq, trades, total_dividend_net=0.0, initial_capital=1000.0)
assert rc["balanced"] is True
assert rc["residual"] == pytest.approx(0.0)
def test_reconcile_detects_missing_dividend() -> None:
eq = pd.DataFrame({"cash": [700.0]})
trades = [_T("BUY", 300.0, 0.0)]
rc = reconcile(eq, trades, total_dividend_net=0.0, initial_capital=1000.0)
# 期望现金 700,实际 700 → 平衡
assert rc["balanced"] is True
# 若真实有 50 元分红但没记账,现金会多出 50 → 应被检出
rc2 = reconcile(eq, trades, total_dividend_net=50.0, initial_capital=1000.0)
assert rc2["balanced"] is False
assert rc2["residual"] == pytest.approx(-50.0)
def test_reconcile_does_not_include_position_value() -> None:
"""买入的股票仍在账上,其市值不是现金口径的误差。"""
eq = pd.DataFrame({"cash": [200.0]})
trades = [_T("BUY", 800.0, 0.0)]
rc = reconcile(eq, trades, 0.0, 1000.0)
assert rc["balanced"] is True, "1000 − 800 = 200,持仓市值不应进入残差"
# ---------------------------------------------------------------------------
# 行业排除清单:从 backtest.yml 一路走到选股滤网
#
# 本项目最怕的失效形态是「配置写了,但没有任何代码路径会读它」——
# 那样回测照跑、日志照打,只是排除从未生效。下面三个入口都必须接线。
# ---------------------------------------------------------------------------
def test_engine_applies_universe_exclusions_to_selector() -> None:
from hdiv.backtest.engine import BacktestEngine
from hdiv.core.config import load_config
from hdiv.universe.selector import UniverseSelector
expected = list(load_config("backtest").universe_exclusions.industries)
eng = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
assert eng.universe_cfg.industry_exclusions == expected
market = UniverseSelector(eng.universe_cfg)._build_filters()["market"]
assert market.exclude_industries == expected, "黑名单没有传到 market 滤网"
def test_walkforward_and_daily_runners_apply_universe_exclusions() -> None:
"""训练段校准 / 逐日选股各自都持有生效后的筛选配置。
walk-forward 的训练段也要排除:否则冻结分布是在**含被排除行业**的池子上
标定的,与测试段实际能买的池子口径不一致。
"""
from hdiv.backtest.daily import DailyRunner
from hdiv.backtest.walk_forward import WalkForwardRunner
from hdiv.core.config import load_config
expected = list(load_config("backtest").universe_exclusions.industries)
path = "config/strategy/high_dividend_v1.yml"
assert DailyRunner.from_strategy(path).universe_cfg.industry_exclusions == expected
assert WalkForwardRunner.from_strategy(path).universe_cfg.industry_exclusions == expected
# ---------------------------------------------------------------------------
# 目标仓位阶梯(防「分批建仓/减仓互相冲突」)
# ---------------------------------------------------------------------------
@pytest.fixture
def engine():
from hdiv.backtest.engine import BacktestEngine
return BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
def test_target_weight_ladder(engine) -> None:
"""plan.md §19 的建仓阶梯 + §18 的减仓阶梯,必须合成单一函数。"""
assert engine._target_weight(95) == pytest.approx(1.00)
assert engine._target_weight(88) == pytest.approx(0.75)
assert engine._target_weight(82) == pytest.approx(0.50)
assert engine._target_weight(76) == pytest.approx(0.25)
assert engine._target_weight(45) == pytest.approx(0.50)
assert engine._target_weight(30) == pytest.approx(0.50)
assert engine._target_weight(10) == pytest.approx(0.00)
def test_target_weight_has_dead_zone(engine) -> None:
"""死区必须存在 —— 否则分位抖动会导致高频无效交易(原实现年换手 8.9)。"""
for pct in (51, 60, 70, 74.9):
assert engine._target_weight(pct) is None, f"分位 {pct} 应落在死区"
def test_target_weight_is_monotonic_on_entry_side(engine) -> None:
"""买入侧:分位越高仓位越重(单调不减)。"""
vals = [engine._target_weight(p) for p in (75, 80, 85, 90)]
assert all(v is not None for v in vals)
assert vals == sorted(vals)
def test_target_weight_is_monotonic_on_exit_side(engine) -> None:
"""卖出侧:分位越低仓位越轻(单调不减)。"""
vals = [engine._target_weight(p) for p in (10, 25, 40, 50)]
assert all(v is not None for v in vals)
assert vals == sorted(vals)
def test_held_position_at_high_percentile_is_not_trimmed(engine) -> None:
"""已持仓且分位很高时不应被误判为减仓 —— 这是原实现的真实 bug。"""
assert engine._target_weight(92) == pytest.approx(1.0)
# ---------------------------------------------------------------------------
# 参数扫描的耦合处理
# ---------------------------------------------------------------------------
def test_parse_sweep() -> None:
g = parse_sweep("entry.yield_percentile=70,75,80")
assert g == {"entry.yield_percentile": [70, 75, 80]}
g2 = parse_sweep("a.b=1,2;c.d=x,y")
assert g2 == {"a.b": [1, 2], "c.d": ["x", "y"]}
def test_parse_sweep_rejects_bad_format() -> None:
from hdiv.core.errors import SchemaValidationError
with pytest.raises(SchemaValidationError):
parse_sweep("entry.yield_percentile")
def test_apply_sweep_shifts_ladder_shape() -> None:
"""扫描 entry.yield_percentile 时必须整体平移 scale_in,而非只改首档。"""
r = StrategyRegistry()
s = r.load("config/strategy/high_dividend_v1.yml")
original_weights = [st.weight for st in s.entry.scale_in]
for p in (65, 70, 80, 90, 95):
x = apply_sweep(s, {"entry.yield_percentile": p})
ladder = [st.percentile for st in x.entry.scale_in]
assert x.entry.yield_percentile == pytest.approx(ladder[0])
assert ladder == sorted(set(ladder)), f"P{p} 的阶梯必须严格升序:{ladder}"
assert max(ladder) <= 100, f"P{p} 的阶梯越界:{ladder}"
# 权重形状必须保持
assert [st.weight for st in x.entry.scale_in] == original_weights
def test_apply_sweep_exit_coupling() -> None:
r = StrategyRegistry()
s = r.load("config/strategy/high_dividend_v1.yml")
x = apply_sweep(s, {"exit.yield_percentile": 30})
assert x.exit.yield_percentile == pytest.approx(30)
assert x.exit.scale_out[-1].percentile == pytest.approx(30)
def test_apply_sweep_unknown_path() -> None:
from hdiv.core.errors import SchemaValidationError
r = StrategyRegistry()
s = r.load("config/strategy/high_dividend_v1.yml")
with pytest.raises(SchemaValidationError):
apply_sweep(s, {"entry.not_a_field": 1})
# ---------------------------------------------------------------------------
# 敏感性判读
# ---------------------------------------------------------------------------
def test_sensitivity_analysis_flags_spike() -> None:
"""plan.md §27 的尖峰情形必须被识别为疑似过拟合。"""
from hdiv.analysis.sensitivity import SensitivityRunner
points = [
{"cagr": 0.13, "max_drawdown": -0.2},
{"cagr": 0.135, "max_drawdown": -0.2},
{"cagr": 0.20, "max_drawdown": -0.2},
{"cagr": 0.132, "max_drawdown": -0.2},
{"cagr": 0.128, "max_drawdown": -0.2},
]
a = SensitivityRunner._analyse(points, {"entry.yield_percentile": [75]})
assert a["spikes"], "P80 的 20% 相对邻居是明显尖峰,必须被检出"
assert a["robust"] is False
assert "过拟合" in a["verdict"]
def test_sensitivity_analysis_accepts_smooth_curve() -> None:
"""plan.md §27 的平滑情形应被判定为对参数不敏感。"""
from hdiv.analysis.sensitivity import SensitivityRunner
points = [{"cagr": c, "max_drawdown": -0.2} for c in (0.13, 0.133, 0.135, 0.132, 0.130)]
a = SensitivityRunner._analyse(points, {"entry.yield_percentile": [75]})
assert not a["spikes"]
assert a["smoothness"] > 0.6
assert a["robust"] is True
assert "不敏感" in a["verdict"]
# ---------------------------------------------------------------------------
# Walk-forward 窗口
# ---------------------------------------------------------------------------
def test_walk_forward_windows_are_disjoint_and_ordered() -> None:
w = WalkForwardRunner()
wins = w.windows()
assert wins, "应至少切出一个窗口"
for a, b in zip(wins, wins[1:], strict=False):
assert a.test_end < b.test_start, "测试区间不得重叠"
assert a.test_start > a.train_end, "测试必须晚于训练"
for x in wins:
assert x.train_start < x.train_end < x.test_start <= x.test_end
def test_walk_forward_freeze_is_enforced_by_config() -> None:
"""plan.md §25:测试阶段禁止重新调参 —— 配置层必须拒绝关闭该开关。"""
cfg = load_config("backtest")
assert cfg.walk_forward.freeze_params_in_test is True
def test_add_months_and_years() -> None:
assert _add_months(date(2024, 1, 31), 1) == date(2024, 2, 29), "闰年 2 月"
assert _add_months(date(2023, 1, 31), 1) == date(2023, 2, 28)
assert _add_months(date(2024, 12, 15), 1) == date(2025, 1, 15)
assert _add_years(date(2020, 2, 29), 1) == date(2021, 2, 28)
# ---------------------------------------------------------------------------
# 引擎端到端(小样本,含数据时执行)
# ---------------------------------------------------------------------------
@pytest.mark.db
def test_engine_end_to_end_reconciliation() -> None:
"""完整跑一段回测并验证资金对账必须平衡。"""
from hdiv.backtest.engine import BacktestEngine
db_ok = True
try:
from hdiv.data import db as _db
_db.load_dotenv_once()
_db.list_tables(load_config("datasource"))
except Exception:
db_ok = False
if not db_ok:
pytest.skip("数据库不可用")
engine = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
res = engine.run(start=date(2023, 1, 1), end=date(2024, 6, 28), persist=False, verbose=False)
rc = res["reconciliation"]
assert rc["balanced"], f"资金对账不平,残差 {rc['residual']}"
assert res["equity"]["nav"].iloc[0] == pytest.approx(1.0)
eq = res["equity"]
# 净值必须等于 总市值 / 期初资金
assert (eq["nav"] - eq["total_value"] / res["initial_capital"]).abs().max() < 1e-9
# 总市值必须等于 现金 + 持仓
assert (eq["total_value"] - (eq["cash"] + eq["position_value"])).abs().max() < 1e-6
# 回撤不得为正
assert eq["drawdown"].max() <= 1e-9
@pytest.mark.db
def test_engine_uses_next_open_no_lookahead() -> None:
"""成交日必须晚于信号日(plan.md §6 无未来函数)。"""
from hdiv.backtest.engine import BacktestEngine
try:
from hdiv.data import db as _db
_db.load_dotenv_once()
_db.list_tables(load_config("datasource"))
except Exception:
pytest.skip("数据库不可用")
engine = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
res = engine.run(start=date(2023, 1, 1), end=date(2024, 6, 28), persist=False, verbose=False)
for t in res["trades"]:
assert t.execution_date > t.signal_date, (
f"{t.symbol} 成交日 {t.execution_date} 未晚于信号日 {t.signal_date}"
)
# ---------------------------------------------------------------------------
# 分红与公司行为(plan.md §30/§31)
# ---------------------------------------------------------------------------
def _div_ctx(day: date, rows: list[dict]) -> dict:
return {"div_by_date": {day: rows}}
def _held(
symbol: str = "X.SH",
*,
quantity: float = 1000.0,
price: float = 10.0,
first_buy: date | None = None,
) -> Position:
d = first_buy or date(2020, 1, 2)
return Position(
symbol=symbol, quantity=quantity, avg_cost=price, cost_basis=quantity * price,
first_buy_date=d, last_buy_date=d,
)
def test_pure_stock_dividend_is_not_dropped(engine) -> None:
"""10 送 10(无现金分红)必须照常调整股数,不得静默丢弃。
纯送转的 ``cash_div_tax`` 是 NULL/0,而 ``stk_div`` > 0。价格是不复权价,
除权日必然下跌;若按现金分红判空整行跳过,就会凭空记出一笔亏损。
实测本库 2015-2026 区间内高股息池成员有 824 笔纯送转。
"""
day = date(2024, 6, 20)
pos = _held(quantity=1000.0, price=10.0)
ledger: list[dict] = []
cash = engine._apply_dividends(
day,
{"X.SH": pos},
_div_ctx(day, [{"symbol": "X.SH", "cash_div_tax": None, "stk_div": 1.0,
"stk_bo_rate": 1.0, "stk_co_rate": None}]),
0.0,
ledger,
)
# 股价腰斩到 5 元、股数翻倍到 2000 股 → 市值不变
assert pos.quantity == pytest.approx(2000.0)
assert pos.quantity * 5.0 == pytest.approx(10000.0), "10 送 10 前后市值必须不变"
assert pos.avg_cost == pytest.approx(5.0), "总成本不变,每股成本须随股数下降"
assert cash == 0.0, "纯送转不产生现金"
assert ledger[0]["shares_added"] == pytest.approx(1000.0)
assert ledger[0]["stock_div_applied"] is True
def test_stock_dividend_written_as_zero_cash_is_applied(engine) -> None:
"""``cash_div_tax`` 写成 0(而非 NULL)的纯转增同样不能丢。"""
day = date(2024, 6, 20)
pos = _held()
engine._apply_dividends(
day,
{"X.SH": pos},
_div_ctx(day, [{"symbol": "X.SH", "cash_div_tax": 0.0, "stk_div": 0.5,
"stk_co_rate": 0.5}]),
0.0,
[],
)
assert pos.quantity == pytest.approx(1500.0)
assert pos.avg_cost == pytest.approx(10000.0 / 1500.0)
def test_cash_and_stock_dividend_are_independent(engine) -> None:
"""同一行既有现金又有送转:两者都要入账,互不影响。"""
day = date(2024, 6, 20)
pos = _held(first_buy=day - timedelta(days=800)) # 持股 > 1 年 → 免征红利税
ledger: list[dict] = []
cash = engine._apply_dividends(
day,
{"X.SH": pos},
_div_ctx(day, [{"symbol": "X.SH", "cash_div_tax": 0.5, "stk_div": 0.3,
"stk_bo_rate": 0.3}]),
0.0,
ledger,
)
assert cash == pytest.approx(500.0), "持股 > 1 年免征红利税,全额入账"
assert pos.quantity == pytest.approx(1300.0)
assert ledger[0]["gross"] == pytest.approx(500.0)
assert ledger[0]["tax"] == pytest.approx(0.0)
assert ledger[0]["shares_added"] == pytest.approx(300.0)
def test_empty_dividend_row_is_skipped(engine) -> None:
"""既无现金也无送转(数据异常行)才是该跳过的行,且不留账。"""
day = date(2024, 6, 20)
pos = _held()
ledger: list[dict] = []
engine._apply_dividends(
day, {"X.SH": pos},
_div_ctx(day, [{"symbol": "X.SH", "cash_div_tax": 0.0, "stk_div": 0.0}]),
0.0, ledger,
)
assert ledger == []
assert pos.quantity == pytest.approx(1000.0)
def test_dividend_cash_joins_the_investable_pool(engine) -> None:
"""分红现金必须与初始资金同一个现金池 —— 能直接用于买入,不被隔离。
这是「分红再投资」的实际含义:除权日入账 → 下次调仓按目标权重再配置。
"""
day = date(2024, 6, 20)
positions = {"X.SH": _held("X.SH", quantity=10_000.0, price=1.0,
first_buy=day - timedelta(days=800))} # 免税
px = pd.DataFrame(
{"open": [1.0], "close": [1.0]}, index=pd.DatetimeIndex([pd.Timestamp(day)])
)
ctx = {
"div_by_date": {day: [{"symbol": "X.SH", "cash_div_tax": 0.10, "stk_div": None}]},
"px_by_sym": {"X.SH": px, "Y.SH": px},
"suspend": set(),
"limits": {},
}
# 起点现金为 0:下面买得成,只可能来自这笔分红
cash = engine._apply_dividends(day, positions, ctx, 0.0, [])
assert cash == pytest.approx(1000.0), "10000 股 × 每股 0.10 元"
sig = Signal(
symbol="Y.SH", signal_date=day, kind="BUY", target_weight=0.10,
yield_value=0.08, yield_percentile=80.0, price=None, reason={},
)
fill, cash_after, skip = engine._execute(sig, day, cash, positions, ctx, 0)
assert fill is not None, f"分红现金未能用于买入:{skip}"
assert fill.quantity > 0
assert cash_after < cash
assert cash_after >= 0.0
def test_duplicate_dividend_rows_are_credited_once(engine) -> None:
"""同一除权日的多条记录只入账一次(引擎在预载阶段按经济事件聚合)。
实测 002352.SZ 2024-11-07 同时有 0.4 / 1.0 / 1.4 三条记录,逐行入账会把同一笔
分红算两三次(现金与送转都会被放大)。
"""
day = date(2024, 6, 20)
raw = pd.DataFrame([
{"symbol": "X.SH", "ex_date": day, "imp_ann_date": date(2024, 5, 20),
"cash_div_tax": 0.5, "stk_div": None, "stk_bo_rate": None, "stk_co_rate": None},
{"symbol": "X.SH", "ex_date": day, "imp_ann_date": date(2024, 6, 1),
"cash_div_tax": 0.5, "stk_div": 0.4, "stk_bo_rate": None, "stk_co_rate": 0.4},
])
ev = dedupe_dividend_events(raw)
assert len(ev) == 1, "同一 (symbol, ex_date) 必须收敛成一笔"
ctx = {"div_by_date": {day: ev.to_dict("records")}}
pos = _held(first_buy=day - timedelta(days=800)) # 持股 > 1 年 → 免税
cash = engine._apply_dividends(day, {"X.SH": pos}, ctx, 0.0, [])
assert cash == pytest.approx(500.0), "同一笔现金分红只入账一次"
assert pos.quantity == pytest.approx(1400.0), "送转只放大一次"
def test_dividend_handling_notes_match_each_mode() -> None:
"""声明口径必须与实现逐档对应:已实现的组合不得留声明,未实现的必须声明。
背景:`cash_mode: reinvest` 的实际行为一直是「分红现金回落到可投资现金池、
下次调仓按目标权重再配置」,却长期被声明成「未实现」—— 声明与行为两头都不准。
"""
def notes(**kw) -> str:
bt = load_config("backtest").model_copy(deep=True)
for k, v in kw.items():
setattr(bt.dividend, k, v)
return " ".join(dividend_handling_notes(bt))
# 已实现:可投资现金池 + 目标权重再配置,且不涉及未实现的配股
assert notes(cash_mode="reinvest", reinvest_rule="portfolio_rebalance",
handle_stock_dividend=True, handle_rights_issue=False) == ""
# 未实现:同股再投 / 永久留存 / 移出组合 / 不处理送转 / 配股,逐条都要声明
assert "未实现 reinvest_rule" in notes(reinvest_rule="same_stock_next_open",
handle_rights_issue=False)
assert "cash_mode=hold" in notes(cash_mode="hold", handle_rights_issue=False)
assert "cash_mode=cash_out" in notes(cash_mode="cash_out", handle_rights_issue=False)
assert "handle_stock_dividend" in notes(handle_stock_dividend=False,
handle_rights_issue=False)
assert "配股" in notes(handle_rights_issue=True)
@pytest.mark.db
def test_engine_dividends_are_creditable() -> None:
"""持有期间应确实收到现金分红(高股息策略的核心收益来源)。"""
from hdiv.backtest.engine import BacktestEngine
try:
from hdiv.data import db as _db
_db.load_dotenv_once()
_db.list_tables(load_config("datasource"))
except Exception:
pytest.skip("数据库不可用")
engine = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
res = engine.run(start=date(2021, 1, 1), end=date(2024, 6, 28), persist=False, verbose=False)
assert res["total_dividend_net"] > 0, "高股息策略在 3.5 年里不可能没有现金分红"
d = res["dividends"]
assert not d.empty
assert (d["net"] <= d["gross"] + 1e-9).all(), "税后不得大于税前"
assert (d["tax"] >= 0).all()
# 同一 (symbol, ex_date) 不得出现两笔入账 —— 库里同一除权日有多条 `实施`
# 记录(全库 1401 组),引擎必须在预载阶段按经济事件聚合
assert not d.duplicated(["ex_date", "symbol"]).any(), (
f"同一除权日重复入账:{d[d.duplicated(['ex_date', 'symbol'], keep=False)]}"
)
@pytest.mark.db
def test_engine_pit_discipline_reference_window(engine) -> None:
"""rolling 参照窗口必须完全落在评估日之前(无未来函数)。"""
for day in (date(2022, 6, 30), date(2024, 1, 15)):
ref = engine._reference_window(day)
assert ref is not None
assert ref[1] == day, "参照窗口右端必须是评估日本身"
assert ref[0] < day
assert engine._reference_mode() == "rolling"
def test_frozen_reference_overrides_rolling() -> None:
"""frozen 模式下必须使用冻结窗口,不得回退到滚动窗口。"""
from hdiv.backtest.engine import BacktestEngine
s = StrategyRegistry().load("config/strategy/high_dividend_v1.yml")
frozen = (date(2015, 1, 1), date(2019, 12, 31))
e = BacktestEngine(s, frozen_reference=frozen)
assert e._reference_window(date(2021, 6, 30)) == frozen
assert e._reference_mode() == "frozen"
def test_reference_window_end_is_evaluation_day() -> None:
"""rolling 窗口的右边界必须是评估日 —— 否则会用未来数据。"""
from hdiv.backtest.engine import BacktestEngine
s = StrategyRegistry().load("config/strategy/high_dividend_v1.yml")
e = BacktestEngine(s)
day = date(2023, 5, 10)
lo, hi = e._reference_window(day)
assert hi == day
assert (day - lo).days == int(365.25 * e.bt_cfg.percentile_reference.lookback_years)
# ---------------------------------------------------------------------------
# 绩效指标
# ---------------------------------------------------------------------------
def test_compute_metrics_flags_insufficient_data() -> None:
from hdiv.analysis.performance import compute_metrics
m = compute_metrics(pd.DataFrame(), [], load_config("backtest"), "r1")
assert m == {}, "空曲线不应编造指标"
eq = pd.DataFrame({
"trade_date": [date(2024, 1, 2)],
"total_value": [1_000_000.0],
"daily_return": [0.0],
"drawdown": [0.0],
"cash": [1_000_000.0],
"position_value": [0.0],
})
m2 = compute_metrics(eq, [], load_config("backtest"), "r2")
assert m2["total_return"] == pytest.approx(0.0)
assert m2["max_drawdown"] == pytest.approx(0.0)
def test_metrics_do_not_invent_values() -> None:
"""样本不足时 Sharpe 必须为 None,而不是 0。"""
from hdiv.analysis.performance import compute_metrics
eq = pd.DataFrame({
"trade_date": [date(2024, 1, 2), date(2024, 1, 3)],
"total_value": [1_000_000.0, 1_010_000.0],
"daily_return": [0.0, 0.01],
"drawdown": [0.0, 0.0],
"cash": [0.0, 0.0],
"position_value": [1_000_000.0, 1_010_000.0],
})
m = compute_metrics(eq, [], load_config("backtest"), "r3")
assert m["sharpe"] is None, "1 个观测算不出波动率,Sharpe 必须是 None"
# ---------------------------------------------------------------------------
# 分位参照的最小样本量保护
# ---------------------------------------------------------------------------
def test_min_observations_config_exists_with_sane_default() -> None:
"""回归:分位参考必须设最小样本量,否则退化分布会伪造 100% 分位。
分位 = 「≤当前值的观测占比」。窗口里只有 1 个观测且恰好等于当前值时
占比 100%,击穿任何买入阈值 —— 实测 2015-01-06(行情数据首日)
8 只股票因此被「100% 分位」买入。
"""
from hdiv.core.config import load_config
ref = load_config("backtest").percentile_reference
assert hasattr(ref, "min_observations"), "缺少 min_observations 配置"
assert ref.min_observations >= 60, \
f"最小样本量过低({ref.min_observations}),至少应约一个季度"
def test_engine_guards_against_insufficient_reference_sample() -> None:
"""引擎必须在样本不足时跳过信号,而不是照常算分位。"""
import inspect
from hdiv.backtest import engine as eng
src = inspect.getsource(eng)
assert "min_observations" in src, "引擎未使用 min_observations"
# 保护必须在计算 pct 之前,且以 continue 跳过该股当日
i_guard = src.find("ref_ser.size < self.bt_cfg.percentile_reference.min_observations")
i_pct = src.find("pct = float((ref_ser <= current)")
assert i_guard != -1, "未找到最小样本量判断"
assert i_pct != -1 and i_guard < i_pct, "样本量判断必须早于分位计算"
assert "continue" in src[i_guard:i_pct], "样本不足应跳过(continue)而非降级计算"
@pytest.mark.db
def test_recent_backtests_have_no_weak_sample_trades() -> None:
"""按新配置跑出的回测不应存在弱样本成交(样本 < min_observations)。"""
from hdiv.core.config import load_config
from hdiv.data import db
db.load_dotenv_once()
cfg = load_config("datasource")
min_obs = load_config("backtest").percentile_reference.min_observations
df = db.read_sql(
"SELECT run_id, COUNT(*) AS n FROM hd_backtest_trade "
"WHERE JSON_EXTRACT(reason_json, '$.observation_count') IS NOT NULL "
" AND JSON_EXTRACT(reason_json, '$.observation_count') < :m "
" AND run_id IN (SELECT run_id FROM hd_backtest_run "
" WHERE created_at > '2026-10-03 14:30:00') "
"GROUP BY run_id",
{"m": min_obs}, cfg=cfg,
)
assert df.empty, (
f"存在弱样本成交的回测(应为 0):"
f"{[(r['run_id'][:10], int(r['n'])) for _, r in df.iterrows()]}"
)
# ---------------------------------------------------------------------------
# 实时画像闸门(entry.profile_gate)
# ---------------------------------------------------------------------------
def _trigger_ctx(sym: str = "000001.SZ", n: int = 280) -> dict:
"""构造一个「股息率处于历史最高分位」的最小上下文。
每股分红恒定 1 元、股价从 20 元跌到 10 元 → 股息率从 5% 升到 10%,
当前值即窗口最大值,分位 = 100% ≥ P75,必然触发买入条件。
``n`` 必须 **同时** 满足两个约束:
- ≥ ``backtest.yml: percentile_reference.min_observations``(250,否则引擎跳过);
- ≈ ≤ 410 个自然日(TTM 分红窗口 365 + 宽限 45),否则序列尾部 TTM 分红归零、
股息率变成 0、分位塌到 30% 以下,触发不了买入。280 个交易日 ≈ 392 天,两者都满足。
"""
days = pd.bdate_range("2015-01-05", periods=n)
close = np.concatenate([np.full(n - 50, 20.0), np.linspace(20.0, 10.0, 50)])
px = pd.DataFrame({"open": close, "close": close}, index=days)
ev = pd.DataFrame([{
"ex_date": days[0], "imp_ann_date": days[0], "cash_div_tax": 1.0,
}])
px_by_sym = {sym: px}
events = {sym: ev}
# P2 之后引擎从 ctx["yield_by_sym"] 取预计算的股息率序列;这里用**同一个**
# 生产函数构造,避免测试自己算一套(那就成了两套口径)。
return {"px_by_sym": px_by_sym, "events": events,
"yield_by_sym": build_yield_series(px_by_sym, events),
"_last_day": days[-1].date()}
def _pass_gate(*_a, **_k) -> dict:
return {"verdict": "PASS", "checks": [], "failed": [], "unverifiable": []}
def _reject_gate(*_a, **_k) -> dict:
return {
"verdict": "REJECT",
"checks": [{"metric": "payout_ratio", "stat": "current_value", "op": "<=",
"threshold": 1.0, "actual": 1.4, "status": "OK",
"window_years": 0, "passed": False}],
"failed": ["payout_ratio.current_value<=1"],
"unverifiable": [],
}
def test_gate_rejects_buy(engine, monkeypatch) -> None:
"""闸门不通过时必须改为 REJECT,且不产生 BUY。"""
ctx = _trigger_ctx()
day = ctx["_last_day"]
monkeypatch.setattr(engine, "_gate", _reject_gate)
sigs = engine._evaluate(day, 1e6, {}, {"000001.SZ"}, ctx)
kinds = [s.kind for s in sigs]
assert "BUY" not in kinds, f"闸门未拦住买入:{kinds}"
assert kinds == ["REJECT"]
rej = sigs[0]
assert rej.reason["skip_reason"] == "PROFILE_GATE"
assert rej.reason["executed"] is False
# 「为什么不买」必须可追溯:逐条规则的实际值与阈值都要留下
chk = rej.reason["profile_checks"]["payout_ratio"]
assert chk["actual"] == pytest.approx(1.4) and chk["threshold"] == 1.0
assert chk["passed"] is False
def test_gate_pass_keeps_buy(engine, monkeypatch) -> None:
"""闸门通过时买入必须照常发生(不能误杀)。"""
ctx = _trigger_ctx()
monkeypatch.setattr(engine, "_gate", _pass_gate)
sigs = engine._evaluate(ctx["_last_day"], 1e6, {}, {"000001.SZ"}, ctx)
kinds = [s.kind for s in sigs]
assert kinds == ["BUY"], kinds
def test_gate_reject_leaves_existing_position_untouched(engine, monkeypatch) -> None:
"""闸门语义是「不值得买」,不是「该卖」—— 被拒时不得动已有仓位。"""
ctx = _trigger_ctx()
pos = {"000001.SZ": Position(symbol="000001.SZ", quantity=1000.0, avg_cost=15.0,
first_buy_date=date(2015, 1, 5), last_buy_date=date(2015, 1, 5),
cost_basis=15000.0)}
monkeypatch.setattr(engine, "_gate", _reject_gate)
sigs = engine._evaluate(ctx["_last_day"], 1e6, pos, {"000001.SZ"}, ctx)
kinds = [s.kind for s in sigs]
assert "TRIM" not in kinds and "SELL" not in kinds and "ADD" not in kinds, kinds
assert kinds == ["REJECT"], "高仓位侧被拒时应只留痕,不调仓"
def test_gate_handles_unverifiable_conservatively(engine, monkeypatch) -> None:
"""无法验证(数据缺失/样本不足)时按配置保守处理,且理由要能区分。"""
def _unver(*_a, **_k):
return {"verdict": "REJECT", "checks": [
{"metric": "roe_avg", "stat": "current_value", "op": ">=", "threshold": 0.08,
"actual": None, "status": "INSUFFICIENT", "window_years": 0, "passed": None}],
"failed": [], "unverifiable": ["roe_avg.current_value"]}
ctx = _trigger_ctx()
monkeypatch.setattr(engine, "_gate", _unver)
sigs = engine._evaluate(ctx["_last_day"], 1e6, {}, {"000001.SZ"}, ctx)
assert [s.kind for s in sigs] == ["REJECT"]
assert "无法验证" in sigs[0].reason["rule"]
assert "样本不足" in sigs[0].reason["reason_cn"]
def test_gate_disabled_returns_none(engine) -> None:
"""闸门关闭时 _gate 必须返回 None —— 调用方不产生任何额外行为。"""
from hdiv.backtest.engine import BacktestEngine
eng = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
eng.gate_cfg.enabled = False
eng.pit = object() # 即使被注入也不得被使用
assert eng._gate("000001.SZ", date(2018, 5, 18)) is None
def test_gate_enabled_but_uninitialized_fails_loudly() -> None:
"""启用但未初始化必须报错,不得静默放行(否则等于风控悄悄失效)。"""
from hdiv.backtest.engine import BacktestEngine
from hdiv.core.errors import HdivError
eng = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
eng.gate_cfg.enabled = True
eng.pit = None
with pytest.raises(HdivError) as ei:
eng._gate("000001.SZ", date(2018, 5, 18))
assert "_prepare" in str(ei.value)
@pytest.mark.db
def test_gate_enabled_backtest_records_rejections() -> None:
"""端到端:启用闸门的短区间回测必须留下可追溯的 REJECT 记录且资金对账平衡。"""
from hdiv.backtest.engine import BacktestEngine
try:
eng = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
assert eng.gate_cfg.enabled is True, "默认策略应已启用实时画像闸门"
res = eng.run(start=date(2016, 1, 1), end=date(2016, 12, 31),
persist=False, verbose=False)
except Exception as exc: # 数据不可用
pytest.skip(f"数据库不可用:{exc}")
assert res["reconciliation"]["balanced"] is True
stats = res["profile_gate"]
assert stats["asof_contexts"] > 0, "应产生实时画像时点"
rejects = [s for s in res["signals"] if s.kind == "REJECT"]
assert rejects, "该区间应存在被画像剔除的买入信号"
for s in rejects:
assert s.reason["skip_reason"] == "PROFILE_GATE"
gate = s.reason["profile_gate"]
assert gate["verdict"] in {"REJECT", "UNVERIFIABLE"}
assert gate["checks"], "每条 REJECT 都必须带逐规则留痕"
assert gate["failed"] or gate["unverifiable"]
for c in gate["checks"]:
assert set(c) >= {"metric", "op", "threshold", "actual", "status", "passed"}
@pytest.mark.db
def test_unimplemented_declarations_are_honest() -> None:
"""自我声明必须两头都准:既不能漏报「配置写了但没实现」,
也不能把「这批股票恰好没停牌」误报成「数据缺失」。
背景:2026-10-04 之前,``unimplemented`` 用「过滤后集合为空」判定约束失效,
于是 2026-08~09(hd_suspend 明明覆盖到 2026-09-30,只是这批股票没停牌)
被声明成「hd_suspend 无数据」—— 把自己的建模正常状态说成数据缺陷。
"""
from hdiv.backtest.engine import BacktestEngine
try:
eng = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
res = eng.run(start=date(2026, 8, 3), end=date(2026, 9, 30),
persist=False, verbose=False)
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
decl = " ".join(res["unimplemented"])
# ① 不得把「无停牌」误报成「无数据」(约束表在 2010 起有数据)
assert "无数据" not in decl, f"误报数据缺失:{decl}"
# ② 必须如实声明「配置承诺但未实现」的项
for must in ("defer", "配股", "成交量占比"):
assert must in decl, f"漏报未实现项 {must}:{decl}"
# ③ 另一头也要准:**已实现**的组合不得留声明。当前配置
# (cash_mode=reinvest + reinvest_rule=portfolio_rebalance)的实际行为是
# 「分红现金回落到可投资现金池、下次调仓按目标权重再配置」,声明它
# 「未实现」会让使用者误以为分红现金被隔离成了不可投资资金。
assert "分红再投资" not in decl, f"把已实现的分红再投资误报成未实现:{decl}"
assert "reinvest_rule" not in decl, f"把已实现的再投资规则误报成未实现:{decl}"
# ---------------------------------------------------------------------------
# 卖出复核(修复 3b):TTM 窗口台阶 vs 分红能力恶化
# ---------------------------------------------------------------------------
def _confirm_engine():
"""构造一个不取数的引擎实例:卖出复核只依赖 pending_div 与策略配置。"""
from hdiv.backtest.engine import BacktestEngine
return BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
def test_exit_confirmation_blocks_cashflow_timing_artefact() -> None:
"""实测 600690.SH 2026-07-30:可见现金只剩 0.26920,但 FY2025 年度 0.89151
早在 2026-06-25 就已公告(除权 2026-08-21)→ 清仓必须被拦下。
这就是用户报告的那笔「实际不该成交」的卖出:旧口径下 TTM 因重复记录虚高
到 2.46848,随后塌到 0.53840/0.26920,把「现金流时点」误读成「分红能力恶化」。
"""
eng = _confirm_engine()
eng.pending_div = {"600690.SH": [(date(2026, 6, 25), 0.89151)]}
ok, info = eng._exit_confirmed("600690.SH", date(2026, 7, 30), 0.26920)
assert ok is False, "已公告未除权的分红足以抬高股息率,清仓应被判定为未确认"
assert info["pending_dps"] == pytest.approx(0.89151)
assert info["ratio"] < info["min_ratio"]
assert eng.exit_unconfirmed == 1
def test_exit_confirmation_allows_real_dividend_cut() -> None:
"""真降息必须照常清仓:公告金额本身就低(或没有公告)时不得拦截。"""
eng = _confirm_engine()
# 无任何已公告未除权分红 → 股息率低就是低
eng.pending_div = {}
ok, info = eng._exit_confirmed("600690.SH", date(2026, 7, 30), 0.26920)
assert ok is True
assert info["pending_dps"] == 0.0
# 公告的是一笔很小的分红(0.02),不足以把股息率抬高 → 仍应清仓
eng2 = _confirm_engine()
eng2.pending_div = {"X.SH": [(date(2026, 6, 25), 0.02)]}
ok2, info2 = eng2._exit_confirmed("X.SH", date(2026, 7, 30), 0.26920)
assert ok2 is True, f"小额公告不应拦住清仓,ratio={info2['ratio']}"
def test_exit_confirmation_is_pit_sensitive() -> None:
"""公告日之后才可见:公告日之前的那一天不得用未来公告去豁免清仓。"""
eng = _confirm_engine()
eng.pending_div = {"600690.SH": [(date(2026, 6, 25), 0.89151)]}
# 公告前一天:当时确实不可知 → 照常清仓
ok_before, info_before = eng._exit_confirmed(
"600690.SH", date(2026, 6, 24), 0.26920
)
assert ok_before is True
assert info_before["pending_dps"] == 0.0
# 公告当天起可见
ok_after, info_after = eng._exit_confirmed("600690.SH", date(2026, 6, 25), 0.26920)
assert ok_after is False
assert info_after["pending_dps"] == pytest.approx(0.89151)
def test_exit_confirmation_can_be_disabled() -> None:
"""关掉开关时行为与修复前逐字一致(回滚路径必须可用)。"""
from hdiv.core.config import ExitConfig
eng = _confirm_engine()
eng.pending_div = {"600690.SH": [(date(2026, 6, 25), 0.89151)]}
eng.strategy.exit = ExitConfig.model_validate(
{"yield_percentile": 25, "scale_out": [], "confirm": {"enabled": False}}
)
ok, info = eng._exit_confirmed("600690.SH", date(2026, 7, 30), 0.26920)
assert ok is True and info["enabled"] is False