"""TTM 每股分红的**财年语义**测试(修复:中期分红导致的断档虚低)。 背景(真实数据,600690.SH 海尔智家): | 分红 | 除权日 | 金额 | |---|---|---:| | FY2024 年度 | 2025-07-25 | 0.96504 | | FY2025 中期 | 2025-11-07 | 0.26920 | | FY2025 年度 | 2026-08-21 | 0.89151 | 旧实现的「按后继接管」只看**相邻两次除权的间隔**:FY2024 与 FY2025 中期只隔 105 天(< 320),于是把中期分红当成「年内多次分红」,两者互不取代; 而 FY2024 与 FY2025 年度相隔 392 天(> 365),旧的又撑不到新的入场。 结果是 **2026-07-25 ~ 2026-08-21 出现 28 天空窗**:TTM 每股分红从 1.23424 掉到 0.26920(−78%),期间公司没有任何真实现金事件。 后果是真实的错误成交:该 run 在 2026-07-30 用这个虚低的股息率(2.31%, 历史分位 1.57% ≤ P25)产生卖出信号,次日开盘清仓 —— 实际上不该卖。 修法:把「相邻间隔」换成**财年语义** —— 同一 ``end_date``(财年)内的多次 支付互不取代;跨财年时,只有「同财年更早还有支付」或「跨财年间隔 < 320 天」 才按标准 365 天窗口退出(中期分红不该把上一年度提前挤掉),否则交给后继接管。 本文件锁定这些语义,与 ``test_dividend_smoothing.py``(相邻间隔的两种毛刺) 互补。 """ from __future__ import annotations import numpy as np import pandas as pd import pytest from hdiv.factor.dividend_yield import ( dedupe_dividend_events, ttm_dps_series, ) TTM = 365 GRACE = 45 def _events(rows: list[tuple[str, str, str, float]]) -> pd.DataFrame: """构造分红事件表:(财年报告期, 除权日, 公告日, 金额)。""" return pd.DataFrame({ "end_date": pd.to_datetime([r[0] for r in rows]), "ex_date": pd.to_datetime([r[1] for r in rows]), "imp_ann_date": pd.to_datetime([r[2] for r in rows]), "cash_div_tax": [r[3] for r in rows], }) def _daily(start: str, end: str) -> pd.DatetimeIndex: return pd.date_range(start, end, freq="D") #: 600690.SH 海尔智家的真实节奏(3 个财年,含两次跨财年间隔) HAIER = _events([ ("2023-12-31", "2024-08-16", "2024-08-10", 0.80131), ("2024-12-31", "2025-07-25", "2025-07-19", 0.96504), # 与上一笔相隔 343 天 ("2025-06-30", "2025-11-07", "2025-11-01", 0.26920), # 中期,相隔 105 天 ("2025-12-31", "2026-08-21", "2026-08-15", 0.89151), # 年度,相隔 287 天 ]) def test_interim_does_not_create_gap_before_next_annual() -> None: """中期分红结束到次年年度除权之间**不得**出现断档虚低。 这是本 bug 的核心:2026-07-25 之后 FY2024 年度已过 365 天, 而 FY2025 年度要到 2026-08-21 才除权;旧实现在这段里只剩中期分红 0.2692。 正确结果应是「FY2024 年度 + FY2025 中期」仍同时在窗口内 = 1.23424。 """ d = _daily("2024-01-01", "2027-12-31") sm = pd.Series( ttm_dps_series(d, HAIER, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True), index=d, ) # 出问题的那一天(真实回测里触发误卖的信号日) assert sm.loc[pd.Timestamp("2026-07-27")] == pytest.approx(1.23424, abs=1e-5) # 2026-07-25(FY2024 到期日)到 2026-08-21(FY2025 年度除权)之间: # 不得低于「最近一笔真实分红」(0.96504),实际上应保持 1.23424 win = sm.loc[pd.Timestamp("2026-07-25"):pd.Timestamp("2026-08-20")] assert win.min() > 1.0, f"中期分红后出现断档虚低,实际 min={win.min():.5f}" assert win.max() - win.min() < 1e-9, "该区间内不应有任何跳变(无真实现金事件)" def test_no_collapse_between_interim_and_next_annual() -> None: """区间内允许「窗口到期」造成的台阶,但**不允许塌到只剩中期分红**。 修复前:2026-07-25 ~ 2026-08-21 只有 0.26920(−78% 的假低点)。 修复后:TTM 全程不低于 1.23(上一年度 + 本年度中期同时在窗口内)。 """ d = _daily("2025-06-01", "2026-12-31") sm = pd.Series( ttm_dps_series(d, HAIER, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True), index=d, ) window = sm.loc[pd.Timestamp("2025-11-07"):pd.Timestamp("2026-08-20")] assert window.min() >= 1.23, f"出现假低点,实际 min={window.min():.5f}" def test_annual_successor_still_takes_over_within_grace() -> None: """跨财年但间隔落在宽限期内(370 天)时,仍应由后继接管、不留空窗。""" ev = _events([ ("2023-12-31", "2024-06-01", "2024-05-25", 1.0), ("2024-12-31", "2025-06-06", "2025-05-30", 1.2), # 相隔 370 天 ("2025-12-31", "2026-06-11", "2026-06-04", 1.2), ]) d = _daily("2024-01-01", "2026-06-11") sm = pd.Series( ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True), index=d, ) win = sm.loc[pd.Timestamp("2024-06-01"):pd.Timestamp("2025-06-06")] assert win.min() > 0.9, f"宽限期内的跨财年断档未被填补,实际 min={win.min():.5f}" def test_interim_alone_does_not_extend_into_a_full_year_of_silence() -> None: """只有中期分红、随后真停发:必须如实归零,不能靠财年语义永远挂着。""" ev = _events([ ("2024-12-31", "2025-07-25", "2025-07-19", 0.96504), ("2025-06-30", "2025-11-07", "2025-11-01", 0.26920), # 之后没有任何分红 ]) d = _daily("2025-01-01", "2027-06-30") sm = pd.Series( ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True), index=d, ) # 中期那笔(本序列最后一笔)的宽限期封顶 = 2025-11-07 + 365 天 assert sm.loc[pd.Timestamp("2027-01-01")] == pytest.approx(0.0) # 但在封顶之前仍如实计入(不是提前归零) assert sm.loc[pd.Timestamp("2026-11-01")] == pytest.approx(0.26920, abs=1e-5) def test_smooth_off_is_unaffected_by_fiscal_year_logic() -> None: """``smooth_spikes=False`` 必须精确保留「硬窗口」语义(回归对照)。""" d = _daily("2025-01-01", "2026-12-31") off = pd.Series( ttm_dps_series(d, HAIER, ttm_days=TTM, grace_days=GRACE, smooth_spikes=False), index=d, ) # 硬窗口:2026-07-27 时 FY2024 年度(2025-07-25)已过 365 天 → 只剩中期 assert off.loc[pd.Timestamp("2026-07-27")] == pytest.approx(0.26920, abs=1e-5) # 而 2025-12-01 时 FY2024 + 中期都在窗口内 assert off.loc[pd.Timestamp("2025-12-01")] == pytest.approx(1.23424, abs=1e-5) def test_fiscal_year_is_inferred_when_end_date_missing() -> None: """事件表没有 ``end_date`` 列时必须退化到「按相邻间隔接管」,不得报错。 退化路径**不做**跨财年补位:退化的财年是「除权日所在年」,会把年报除权年 与紧随其后的中期除权年混在一起,据此补位反而可能造出错误窗口。宁可保守 —— 业务路径的分红记录都带 ``end_date``(见 ``repo.dividend_records``)。 """ ev = HAIER.drop(columns=["end_date"]) d = _daily("2026-07-01", "2026-08-31") out = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True) s = pd.Series(out, index=d) assert np.isfinite(out).all() # 退化路径仍保留旧接管:2026-07-01 是 FY2023 年度 + FY2025 中期 = 1.23424 assert s.loc[pd.Timestamp("2026-07-01")] == pytest.approx(1.23424, abs=1e-5) # 但没有跨财年补位,2026-07-25 起只剩中期分红(记录该已知局限) assert s.loc[pd.Timestamp("2026-07-27")] == pytest.approx(0.26920, abs=1e-5) def test_dedupe_then_fiscal_year_end_to_end() -> None: """重复记录 + 真实节奏同时存在:聚合与财年语义必须叠加生效。 真实库里 600690.SH 的每一笔都有**两条** ``实施`` 记录(ann_date 不同), 逐行累加会把 2.46848 当成 TTM —— 那是虚高一倍的值,随后的「回落」也 必然是假象。 """ dup = pd.concat([HAIER, HAIER], ignore_index=True) deduped = dedupe_dividend_events(dup) assert len(deduped) == len(HAIER), "同一除权日的重复记录未被聚合" d = _daily("2025-08-01", "2026-08-20") sm = pd.Series( ttm_dps_series(d, dup, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True), index=d, ) # 聚合 + 财年接管后: # - 出问题的那一天稳定在 1.23424(FY2024 年度 + FY2025 中期) # - 关键区间(2026-07-25 ~ 2026-08-20)不再塌到 0.26920 # - 全程下限是 0.96504(合理的窗口到期台阶),不是 0.2692 的假低点 assert sm.loc[pd.Timestamp("2026-07-27")] == pytest.approx(1.23424, abs=1e-5) gap = sm.loc[pd.Timestamp("2026-07-25"):pd.Timestamp("2026-08-20")] assert gap.min() == pytest.approx(1.23424, abs=1e-5), "该区间不得出现假低点" assert sm.min() >= 0.96, f"区间内出现假低点,实际 min={sm.min():.5f}" assert sm.max() <= 1.77, f"出现重叠虚高,实际 max={sm.max():.5f}"