Files
ggx/tests/test_dividend_smoothing.py
simon fb6608193b 功能:Web 前端与报告格式化(工作区中此前未提交的工作)
说明:本提交**不是本轮会话所做**,而是工作区里此前遗留的未提交改动。
为把历史分开,先单独提交它,再提交本轮会话的修改。

包含:
- Web 前端:web/index.html、web/app.js(统一 SPA,含回测/画像/Walk-forward 页面)
- 后端接口:web/server.py 路由、web/analysis.py(新增个股分析)
- 报告层:report/format.py(新增统一数字格式化 NumFmt)、
  report/{backtest,profile,sensitivity,universe,walkforward}_report.py 接入 NumFmt、
  report/renderer.py
- 股息率口径:factor/dividend_yield.py(毛刺消除 smooth_spikes)
- 筛选:universe/selector.py、universe/filters/dividend.py
- 绩效/敏感性:analysis/performance.py、analysis/sensitivity.py
- 部署:deploy/install-service.sh
- 测试:tests/test_format.py、tests/test_dividend_smoothing.py(新增)、
  tests/test_web.py、tests/test_universe.py

提交时全量测试 403 项通过。
2026-10-04 12:47:10 +08:00

251 lines
11 KiB
Python
Raw Permalink 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.
"""TTM 股息率毛刺消除测试。
**背景**:A 股相邻两次除权的间隔经常不是 365 天。硬 365 天窗口于是在每年
除权日附近制造两种日历假象:
- **重叠虚高**:间隔 < 365 时新旧分红同时在窗口内。实测招商银行 2015-07-03
股息率 0.620 → 1.290(+108%),10 天后回落到 0.670。
- **断档虚低**:间隔 > 365 时旧的已到期而新的未入场。实测中国神华
2016-07-04:0.740 → 0.320(−57%)。
两者都会污染「历史分位」这一核心信号,且筛选器用的也是同一个数
(直接决定选股),因此必须消除。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from hdiv.factor.dividend_yield import (
build_dps_events,
ttm_dps_at,
ttm_dps_series,
ttm_params,
)
TTM = 365
GRACE = 45
def _events(pairs: list[tuple[str, float]]) -> pd.DataFrame:
"""构造分红事件表:(除权日, 金额)。"""
return pd.DataFrame({
"ex_date": pd.to_datetime([d for d, _ in pairs]),
"imp_ann_date": pd.to_datetime([d for d, _ in pairs]),
"cash_div_tax": [a for _, a in pairs],
})
def _daily(start: str, end: str) -> pd.DatetimeIndex:
return pd.date_range(start, end, freq="D")
# ---------------------------------------------------------------------------
# 核心:两种毛刺都要消除
# ---------------------------------------------------------------------------
def test_overlap_spike_is_removed() -> None:
"""间隔 360 天:新分红入场时旧的不应再计入(消除 +100% 虚高)。"""
d = _daily("2020-01-01", "2023-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2), ("2023-05-22", 1.4)])
raw = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=False), index=d)
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=True), index=d)
# 未平滑时:2022-05-27 当刻涨到 1.0+1.2=2.2,5 天后旧的到期回落
assert raw.max() > 2.1, f"未平滑应出现重叠虚高,实际 max={raw.max()}"
# 平滑后:不应出现两笔相加
assert sm.max() <= 1.45, f"平滑后不应双算,实际 max={sm.max()}"
# 且切换当天不跳变
after = sm.loc[pd.Timestamp("2022-05-27"):].iloc[:5]
assert after.max() / after.min() - 1 < 0.05, "接管当天不应有跳变"
def test_gap_dip_is_filled() -> None:
"""间隔 370 天:旧的到期后应继续计到新的入场(消除断档虚低)。"""
d = _daily("2020-01-01", "2023-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-06-06", 1.2)]) # 间隔 370 天
raw = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=False), index=d)
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=True), index=d)
# 只看「第一笔到第二笔」这段(尾部无后继本就该归零,属正确行为)
win = slice(pd.Timestamp("2021-06-01"), pd.Timestamp("2022-06-06"))
raw_a, sm_a = raw.loc[win], sm.loc[win]
# 未平滑:2022-06-01 旧的到期、新的还没来 → 归零 5 天
assert raw_a.min() == 0.0, "未平滑应出现断档归零"
# 平滑后:同区间不应归零
assert sm_a.min() > 0.9, f"平滑后不应断档,实际 min={sm_a.min()}"
def test_intra_year_multiple_payments_are_not_merged() -> None:
"""年内多次分红(间隔 180 天)必须都保留 —— 否则会把中期分红误删。"""
d = _daily("2021-01-01", "2023-12-31")
ev = _events([
("2022-06-01", 0.3), ("2022-11-28", 0.7),
("2023-05-29", 0.3), ("2023-11-25", 0.7),
])
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=True), index=d)
# 年中确实应同时含两笔(0.3 + 0.7 = 1.0)
peak = sm.loc[pd.Timestamp("2023-06-01"):pd.Timestamp("2023-11-20")]
assert peak.max() > 0.95, f"年内两笔分红应同时计入,实际 max={peak.max()}"
def test_true_cessation_still_goes_to_zero() -> None:
"""真停发必须如实归零,不能因为平滑就永远挂着旧分红。"""
d = _daily("2020-01-01", "2025-12-31")
ev = _events([("2021-06-01", 1.0), ("2023-06-01", 1.0)]) # 中间空了两年
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=True), index=d)
# 2021 那笔在 2022-06-01 + 45 天宽限后必须归零
gap = sm.loc[pd.Timestamp("2022-09-01"):pd.Timestamp("2023-05-31")]
assert gap.max() == 0.0, f"停发期间应归零,实际 max={gap.max()}"
def test_smoothing_can_be_disabled() -> None:
"""smooth_spikes=False 应精确复现旧的「硬窗口 + 归零才兜底」行为。"""
d = _daily("2020-01-01", "2023-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)])
off = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=False)
# 旧行为:重叠期双算
assert off.max() >= 2.1
on = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True)
assert on.max() < off.max()
def test_build_dps_events_matches_series_expectations() -> None:
"""事件表经 build_dps_events 规范化后仍可用。"""
raw = pd.DataFrame({
"symbol": ["X"] * 3,
"ex_date": ["2021-06-01", "2022-05-27", "2023-05-22"],
"imp_ann_date": ["2021-05-25", "2022-05-20", "2023-05-15"],
"cash_div_tax": [1.0, 1.2, 1.4],
})
e = build_dps_events(raw)["X"]
d = _daily("2021-01-01", "2023-12-31")
out = ttm_dps_series(d, e, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True)
assert len(out) == len(d) and out.max() <= 1.45
# ---------------------------------------------------------------------------
# 口径统一:筛选 / 画像 / 回测 / Web 必须用同一份参数
# ---------------------------------------------------------------------------
def test_ttm_params_is_single_source_of_truth() -> None:
"""四处调用点必须都从 ttm_params() 取参,不得各自硬编码。"""
import inspect
from pathlib import Path
root = Path(__file__).resolve().parents[1] / "src" / "hdiv"
# 回测引擎曾硬编码 ttm_days=365, grace_days=45
eng = (root / "backtest" / "engine.py").read_text(encoding="utf-8")
assert "ttm_days=365, grace_days=45" not in eng, "引擎仍在硬编码 TTM 参数"
assert "ttm_params()" in eng
# walk-forward 与 web 曾用函数默认值
for rel in ("backtest/walk_forward.py", "web/analysis.py"):
text = (root / rel).read_text(encoding="utf-8")
assert "ttm_params()" in text, f"{rel} 未使用统一参数"
# 因子层自身
f = inspect.getsource(__import__(
"hdiv.factor.dividend_yield", fromlist=["x"]))
assert "def ttm_params" in f
def test_ttm_dps_at_matches_series_right_endpoint() -> None:
"""单点求值(筛选器用)必须与序列右端点一致。"""
d = _daily("2020-01-01", "2022-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)])
asof = pd.Timestamp("2021-12-31").date()
one = ttm_dps_at(asof, ev)
ser = ttm_dps_series(pd.DatetimeIndex([pd.Timestamp(asof)]), ev,
ttm_days=TTM, grace_days=GRACE, smooth_spikes=True)
assert one is not None
assert abs(one - float(ser[0])) < 1e-9
def test_ttm_params_reads_config() -> None:
from hdiv.core.config import load_config
w, g, sm = ttm_params()
c = load_config("profile").ttm_dividend
assert (w, g, sm) == (c.window_days, c.grace_days, c.smooth_spikes)
def test_config_exposes_smooth_spikes_switch() -> None:
"""开关必须暴露在 YAML 里,用户可自行关闭。"""
from hdiv.core.config import load_config
assert hasattr(load_config("profile").ttm_dividend, "smooth_spikes")
@pytest.mark.parametrize("grace", [0, 10, 45, 90])
def test_smoothing_never_produces_negative_or_nan(grace: int) -> None:
d = _daily("2020-01-01", "2023-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-06-06", 1.2), ("2023-06-01", 1.4)])
out = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=grace, smooth_spikes=True)
assert np.isfinite(out).all()
assert (out >= 0).all()
# ---------------------------------------------------------------------------
# 透传一致性:包装函数必须接收并转发所有参数
# ---------------------------------------------------------------------------
def test_wrapper_signature_forwards_all_params() -> None:
"""回归:`dividend_yield_series` 是 `ttm_dps_series` 的包装。
曾经只给**调用方**加了 `smooth_spikes`,却忘了在包装函数签名里声明,
于是 walk-forward 直接 `TypeError` 崩在第一个窗口 —— 而测试全绿,
因为测试没走 walk-forward 那条路径。
"""
import inspect
from hdiv.factor import dividend_yield as dy
inner = set(inspect.signature(dy.ttm_dps_series).parameters) - {"dates", "events"}
outer = set(inspect.signature(dy.dividend_yield_series).parameters) - {"close", "events"}
missing = inner - outer
assert not missing, (
f"dividend_yield_series 未转发参数 {sorted(missing)};"
"调用方传了就会 TypeError"
)
# 且必须真的往下传
src = inspect.getsource(dy.dividend_yield_series)
for name in inner:
assert f"{name}={name}" in src, f"包装函数未把 {name} 传给 ttm_dps_series"
def test_wrapper_accepts_smooth_spikes() -> None:
"""直接以关键字调用,确保签名真的可用(不只是字符串包含)。"""
from hdiv.factor.dividend_yield import dividend_yield_series
d = _daily("2021-01-01", "2022-12-31")
close = pd.Series(10.0, index=d)
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)])
for flag in (True, False):
out = dividend_yield_series(close, ev, ttm_days=365, grace_days=45,
smooth_spikes=flag)
assert not out.empty
assert "dividend_yield" in out.columns
def test_all_ttm_callers_pass_the_unified_params() -> None:
"""五处调用点都必须显式传 smooth_spikes,不能靠默认值(否则与配置脱钩)。"""
from pathlib import Path
root = Path(__file__).resolve().parents[1] / "src" / "hdiv"
for rel in ("profile/builder.py", "backtest/engine.py",
"backtest/walk_forward.py", "web/analysis.py"):
src = (root / rel).read_text(encoding="utf-8")
assert "smooth_spikes" in src, f"{rel} 未传 smooth_spikes(会与配置脱钩)"