Files
ggx/tests/test_backtest.py
T
simon 14ec0c6c86 修复:量价单位 / 未来函数守卫 / 实时画像闸门;行情回补到 2005;手册补全流程
本轮会话的三项正确性改造(均为「不报错、只让结果静默错」的类型):

1) 修复 stock_daily 量价单位前后不一致
   - 现象:2015-2019 存 Tushare 原始单位(手/千元),2020 起存(股/元),2019 同日混合;
     而流动性阈值按「元」配置 → 早年门槛实际是「日均成交额 ≥ 200 亿元」,
     把 2015-2019 的股票池整体清空(实测 2016/2017/2018 各选出 0 只)。
   - 修复:写入端 sync/price.py 统一换算;读取端 units.normalize_ohlcv_units
     按行判定并幂等换算(price_history / avg_amount 都走它);
     审计新增 UNIT-OHLCV 防回归。
   - 效果:2016/2017/2018 的股票池变为 7/11/13 只。

2) 未来函数守卫(单次回测)
   - 股票池自带 asof:若晚于回测起点即**拒绝执行**(原先静默冻结套用),
     与 walk-forward 已有的拒绝理由一致;确需复现加 --allow-lookahead-universe,
     偏差写入 unimplemented_json。

3) 新增实时(PIT)个股画像闸门
   - profile/pit.py:每个决策日按当时可见数据重算过去 5 年画像,
     惰性(仅买入条件已触发的标的)、面板按 asof 缓存、
     规则不含财务指标时不查财报表;被剔除时产出 REJECT + 逐规则留痕。
   - 指标定义复用 ProfileBuilder._profile_one(与批量画像逐值等价的回归测试)。
   - profile/coverage.py:窗口覆盖率(按交易日历的真实开市天数),
     策略新增 entry.profile_gate.min_window_coverage(默认 0,不改变既有行为)。
   - core/metrics.py:闸门可用指标的唯一定义(配置期即校验,避免写错指标名静默失效)。

4) 行情回补到 2005(使 5/8/10 年窗口真正完整)
   - stock_daily / adjust_factor / daily_basic 补到 2005-01-04;
     hd_suspend / hd_limit 补到 2010-01-04。
   - 5 年窗口覆盖率:2018-05-18 由 67.0% → 99.1%,2016-12-30 由 39.8% → 99.0%;
     残差经逐日与 hd_suspend 交叉核实为真实停牌(16/16 命中)。
   - 审计 G2/G3 与断点续传原先用固定阈值(2000 / 1500 只),
     会把 2005-2009 的正常数据误判为异常 —— 改为按「当年应有上市股票数」成比例判定。
   - 节流修正:daily/adj_factor/daily_basic 限频 480 → 170(实测该 token 约 196/min 即被拒)。

5) 自我声明如实化
   - 原先「约束未生效」由「过滤后集合为空」判定,会把「这批股票恰好没停牌」
     误报成「hd_suspend 无数据」;改为按表级判定。
   - 补齐此前静默的「配置承诺但未实现」项:suspended_rule/limit_up_down_rule 的 defer、
     cash_mode=reinvest/reinvest_rule、handle_rights_issue、signal_to_execution、
     max_volume_pct、liquidity_limit_pct_adv —— 全部写入 unimplemented_json。

6) 手册:新增 §0「全流程操作(选股 → 画像 → 回测)」置于最前
   - 逐步说明「命令做了什么、数据从哪来、落了哪些库、有哪些坑」;
     含实时画像闸门 9 问 9 答、未来函数守卫表、成交与成本口径、验证 SQL。
   - 修正旧 §2.4 漏传 --universe-run(选了池子却没用于回测);
     修正两处声称「停牌顺延」「分红再投资」已实现的相反表述。

测试:403 项全部通过(含新增 test_units.py、test_profile_pit.py、
未实现声明诚实性测试、行序无关性回归测试)。

注意:本提交中 docs/*、README.md、src/hdiv/web/service.py 除本轮修改外,
也含此前遗留的未提交改动(无法按文件切分)。
2026-10-04 12:47:17 +08:00

694 lines
28 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
import numpy as np
import pandas as pd
import pytest
from hdiv.backtest.engine import (
CostModel,
Position,
Signal,
_months_between,
_round_lot,
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,持仓市值不应进入残差"
# ---------------------------------------------------------------------------
# 目标仓位阶梯(防「分批建仓/减仓互相冲突」)
# ---------------------------------------------------------------------------
@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}"
)
@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()
@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,
}])
return {"px_by_sym": {sym: px}, "events": {sym: ev}, "_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}"