Files
ggx/tests/test_daily.py
T
simon cf6d4d2c56 功能:每日动态股票池回测(--mode daily)+ 每日增量同步 + PIT 批量取数层
说明:本提交是工作区中此前的未提交工作(在 14ec0c6 之后产生),**非本次会话所写**,
按用户要求整理并推送。已做安全检查(无明文凭据、无大文件、.env/logs/output 仍被忽略),
并完成可执行范围内的测试验证(见「测试」一节)。

## 新增能力

1) `hdiv backtest --mode daily --start <日期>`
   - src/hdiv/backtest/daily.py:两趟式(先逐日选股,再复用既有引擎模拟)
   - 每个交易日按当日可见数据重建股票池(PIT),每个交易日判断买卖点
   - `pool_exit_action`:hold(只减不加、不因掉出池子而清仓)/ sell(掉出即清仓)
   - `profile_on_trade`:买卖决策发生时计算并留痕个股画像,**不区分是否在当日池内**
     (卖出/减仓同样留痕,否则「为什么卖」缺证据)
   - 与 walkforward 的分工:daily 是一条连续路径的推演,不是过拟合检验;
     因此不使用训练段、不冻结分布,阈值口径一律 rolling
   - 拒绝 `--universe-run`(daily 的定义就是逐日重筛,冻结池与之矛盾)

2) PIT 批量取数层 src/hdiv/universe/pit.py
   - PitRepo 继承 Repo,**只重写取数**(按区块批量预载 + 逐日内存切片),
     派生逻辑(最新一期财报合并、单位归一化、支付率口径等)一行不重写
     —— 以保证与逐日单点查询**结果等价**
   - 候选集预剪枝:用「不可能通过」的边界条件提前排除,文档论证为精确等价而非近似
   - src/hdiv/universe/daily.py:每日动态筛选器(仍然调用既有 selector 与四个 Filter)

3) 每日增量同步 `hdiv sync daily`
   - src/hdiv/data/sync/daily.py:只抓「库里还没有的那几天」,
     按「当日股票数 ≥ 当年规模阈值」判定缺口,不重拉历史、不覆盖既有行;
     支持 `--dry-run` 先看待抓清单
   - deploy/daily-sync.sh、deploy/install-sync-schedule.sh、
     deploy/com.hddiv.sync.plist.example(launchd 每天 17:00)
   - 新表 hd_daily_universe(逐日入选成员留痕)+ sql/hd_daily_universe.sql + schema.py
     (该表已存在于库中,`ddl plan` 返回 0 个待执行动作)

4) Web 与文档
   - 前端支持 daily 模式记录下钻(web/app.js、web/app.css、web/index.html、
     web/favicon.svg)
   - README / docs/user-guide.md / docs/implementation-status.md 同步更新:
     三种回测模式的取舍、daily 的成本说明(6.7 年约 1.5 小时)与调优手段

## 测试

tests/ 共 500 项(新增 tests/test_daily.py 43 项、tests/test_sync_daily.py 36 项)。

已验证通过:
- 排除上述两个新文件的 **421 项:全部通过(pytest 退出码 0)**
- 两个新文件的**非 DB 单元测试 60 项:全部通过**

未能在合理时间内跑完:
- 两个新文件中 **19 项 DB 标记的重型测试**。实测瓶颈是一条**无界全表扫描**:
  `SELECT ... FROM hd_cashflow WHERE ann_date <= :asof ORDER BY symbol, end_date, ann_date`
  (31 万行,无 symbol/报告期下限)。全量套件跑到 161 项时已耗时 20 分钟、
  0 失败,按该速率预计需 3 小时以上,因此改为分档验证。
- 旁证:库中存在 3 次成功的 daily 端到端运行(2026-10-05 10:05 / 10:32 / 11:03,
  区间 2024-03-01~03-15),说明该路径可正常完成。

## 已知待改进

- 上述 `hd_cashflow`(及同类「按 ann_date 上界取全历史」)的查询缺
  symbol / 报告期下限,是 daily 模式的主要性能瓶颈,建议下一轮优化。
2026-10-05 11:57:13 +08:00

986 lines
40 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.
"""每日动态股票池回测(``--mode daily``)测试。
三条必须被锁定的性质:
1. **取数口径不分叉** —— ``PitRepo`` 在某个 asof 上返回的行情/每日指标/财务/
分红,必须与直连数据库的 ``Repo`` **逐值一致**。它只改了「怎么取」,
没有改「怎么算」;这是每日选股能跑得快却仍然可信的前提。
与 ``tests/test_profile_pit.py`` 锁定「实时画像 = 批量画像」是同一个思路。
2. **预剪枝不改变最终入选** —— 市场/风险滤网的候选集预剪枝只剔除「在区间内
不可能通过市场滤网」的股票(交易所/板块/上市年限/市值上界)。开与关必须
选出**完全相同**的成员,否则它是「近似」而不是「等价」。
3. **动态池语义** —— 持仓掉出当日股票池时:``pool_exit_action=hold`` 只停止
加仓(不清仓、仍按分位卖出),``sell`` 则清仓。买卖决策都会留下画像证据,
与它是否在池内无关。
需要数据库的用例标记为 ``db``(无库时跳过)。
"""
from __future__ import annotations
from datetime import date, timedelta
import numpy as np
import pandas as pd
import pytest
from hdiv.core.config import DailyConfig, load_config
from hdiv.universe.daily import DailyUniverseScreener
from hdiv.backtest.daily import _chunks
from hdiv.backtest.engine import build_yield_series
# ---------------------------------------------------------------------------
# 非 DB:配置与纯函数
# ---------------------------------------------------------------------------
class TestDailyConfig:
def test_defaults_are_conservative(self) -> None:
"""默认值必须让 daily 段「什么都没开」不影响既有模式。"""
c = DailyConfig()
assert c.universe_refresh_days == 1
assert c.signal_frequency_days == 1
assert c.pool_exit_action == "hold"
assert c.profile_on_trade is True
assert c.persist_daily_universe is True
assert c.chunk_years == 1
def test_backtest_yml_has_daily_block(self) -> None:
bt = load_config("backtest")
assert bt.daily.universe_refresh_days >= 1
assert bt.daily.pool_exit_action in {"hold", "sell"}
@pytest.mark.parametrize(
"field", ["universe_refresh_days", "signal_frequency_days", "chunk_years"]
)
def test_non_positive_rejected(self, field: str) -> None:
from hdiv.core.errors import SchemaValidationError
with pytest.raises((SchemaValidationError, ValueError)):
DailyConfig(**{field: 0})
def test_bad_pool_exit_action_rejected(self) -> None:
from hdiv.core.errors import SchemaValidationError
with pytest.raises((SchemaValidationError, ValueError)):
DailyConfig(pool_exit_action="liquidate")
class TestChunks:
def test_single_year(self) -> None:
days = [date(2024, 1, 2), date(2024, 3, 1), date(2024, 12, 31)]
out = _chunks(days, 1)
assert out == [days]
def test_year_boundary_splits(self) -> None:
days = [date(2023, 12, 29), date(2024, 1, 2), date(2024, 12, 31),
date(2025, 1, 2)]
out = _chunks(days, 1)
assert [len(c) for c in out] == [1, 2, 1]
assert out[0][0] == date(2023, 12, 29)
assert out[2][0] == date(2025, 1, 2)
def test_multi_year_chunk(self) -> None:
days = [date(2020, 6, 1), date(2021, 6, 1), date(2022, 6, 1),
date(2023, 6, 1), date(2024, 6, 1)]
out = _chunks(days, 2)
assert [len(c) for c in out] == [2, 2, 1]
def test_empty(self) -> None:
assert _chunks([], 1) == []
class TestEngineDefaultsAreOff:
"""新增的引擎参数默认值必须让 single / walkforward 行为逐字不变。"""
def test_new_params_default_off(self) -> None:
from hdiv.backtest.engine import BacktestEngine
from hdiv.strategy.registry import StrategyRegistry
s = StrategyRegistry().load("config/strategy/high_dividend_v1.yml")
eng = BacktestEngine(s)
assert eng.universe_by_refresh is None
assert eng.universe_refresh_days is None
assert eng.signal_frequency_days is None
assert eng.pool_exit_action == "hold"
assert eng.profile_on_trade is False
def test_monthly_signal_path_preserved(self) -> None:
"""按月判定信号的原始语义必须仍在源码里(daily 只是新增分支)。"""
import inspect
from hdiv.backtest import engine as mod
src = inspect.getsource(mod.BacktestEngine._simulate)
assert "freq_days" in src, "daily 的按交易日分支丢失"
assert "_months_between(" in src, "原有按月判定分支被删除"
assert "day_index % max(1, int(freq_days))" in src
def test_pool_exit_branches_present(self) -> None:
import inspect
from hdiv.backtest import engine as mod
src = inspect.getsource(mod.BacktestEngine._evaluate)
assert "in_universe" in src
assert "pool_exit_action" in src
assert "OUT_OF_UNIVERSE" in src
assert "_trade_signal" in src, "买卖信号的画像留痕出口丢失"
class TestMetricFormatting:
"""短区间下指标不可计算时的格式化(**修掉一个既有的崩溃**)。"""
def test_pct_and_num_tolerate_none(self) -> None:
from hdiv.backtest.engine import _num, _pct
assert _pct(None) == "—"
assert _num(None) == "—"
assert _pct(0.1234) == "12.34%"
assert _num(1.5) == "1.50"
assert _pct(float("nan")) == "—"
def test_no_raw_metric_format_in_hot_prints(self) -> None:
"""回测的进度/汇总打印不得直接对可能为 None 的指标做 :.2% 格式化。
实测:15 个交易日的回测里 ``sharpe`` 为 None,直接 ``:.2f`` 会抛
``TypeError`` 并以完整 traceback 结束 —— 把「指标不可计算」这个正常状态
说成了程序缺陷。engine 与 cli 的打印必须走 ``_pct`` / ``_num``。
"""
import inspect
from hdiv import cli
from hdiv.backtest import engine as eng
for fn in (eng.BacktestEngine.run, cli.cmd_backtest):
src = inspect.getsource(fn)
for bad in ("['sharpe']:.2f", "['cagr']:.2%",
"['max_drawdown']:.2%", "['total_return']:.2%"):
assert bad not in src, f"{fn.__qualname__} 仍在直接格式化 {bad}"
@pytest.mark.db
def test_pit_profile_caches_are_bounded() -> None:
"""长区间每日回测**不能**把每个时点的面板都留在内存里(否则必然 OOM)。
每个 ``_AsOfContext`` 持有该 asof 可见的分红超集(约 4 MB)。1600 个决策日
不设上限 ≈ 6 GB。这里用跨越一年的多个 asof 证明缓存是有界的,
同时 ``distinct_asof`` 仍如实汇报**累计**涉及的时点数。
"""
from hdiv.profile.pit import _MAX_ASOF_CONTEXTS, PitProfileService
from hdiv.universe.pit import PitRepo
try:
rows = PitRepo().stock_master()
syms = rows["symbol"].astype(str).head(3).tolist()
except Exception as exc: # pragma: no cover
pytest.skip(f"数据库不可用:{exc}")
if not syms:
pytest.skip("数据库无股票")
svc = PitProfileService(window_years=5)
svc.prepare(syms, date(2019, 1, 1), date(2024, 12, 31))
svc.configure({"roe_avg"})
days = [date(2024, m, 15) for m in range(1, 13)]
for d in days:
for s in syms:
svc.snapshot(s, d)
assert len(svc._ctx) <= _MAX_ASOF_CONTEXTS, "时点面板缓存无上限 —— 长回测会 OOM"
assert len(svc._snapshots) <= len(syms) * 2, "画像快照缓存无上限"
assert svc.stats()["distinct_asof"] == len(days), (
"distinct_asof 必须是累计值,不能用当前缓存条数冒充"
)
# ---------------------------------------------------------------------------
# 前端数据源:决策时点实时画像
# ---------------------------------------------------------------------------
@pytest.mark.db
def test_stock_detail_exposes_decision_time_profile() -> None:
"""成交个股的**决策时点实时画像**必须能从接口取到。
这是前端个股页「决策时点实时画像」卡片的数据源
(``trades[].reason.profile``)。这条链路断了,页面那张卡片会静默变空 ——
而「当时凭什么买」正是 daily 模式最该留下的证据。
"""
from hdiv.data import db
from hdiv.web import analysis
cfg = load_config("datasource")
try:
run = db.read_sql(
"SELECT run_id FROM hd_backtest_run WHERE mode = 'daily' "
"ORDER BY created_at DESC LIMIT 1", cfg=cfg,
)
except Exception as exc: # pragma: no cover
pytest.skip(f"数据库不可用:{exc}")
if run.empty:
pytest.skip("库里还没有 mode=daily 的回测记录")
run_id = str(run["run_id"].iloc[0])
tr = db.read_sql(
"SELECT symbol FROM hd_backtest_trade WHERE run_id = :r "
"AND JSON_EXTRACT(reason_json, '$.profile') IS NOT NULL LIMIT 1",
{"r": run_id}, cfg=cfg,
)
if tr.empty:
pytest.skip(f"{run_id} 的成交里没有画像留痕(可能关闭了 profile_on_trade)")
symbol = str(tr["symbol"].iloc[0])
d = analysis.stock_detail(run_id, symbol, series=["close"])
with_profile = [t for t in d["trades"] if (t.get("reason") or {}).get("profile")]
assert with_profile, "接口未返回决策时点画像(前端卡片会变空)"
prof = with_profile[0]["reason"]["profile"]
# 快照必须带「当时的值」与「当时的窗口」,否则无法复核
assert isinstance(prof.get("values"), dict) and prof["values"], prof.keys()
assert "window_years" in prof and "asof" in prof
# 至少含闸门用到的一项判据,证明与筛选/闸门是同一份画像口径
assert {"dv_yield", "roe_avg", "dividend_continuity_years",
"payout_ratio", "fcf_dividend_cover"} & set(prof["values"])
# 回测详情页的「成交个股的实时画像」表用的是**成交列表**接口,
# 它也必须带 reason.profile(否则那张表会空)。
from hdiv.web import service
tl = service.list_backtest_trades(run_id, size=200)
assert any((it.get("reason") or {}).get("profile") for it in tl["items"]), (
"list_backtest_trades 未返回画像 —— 回测详情页的画像表会空"
)
def test_cli_points_at_the_frontend() -> None:
"""一条命令跑完必须告诉用户「去前端哪里看」,而不是让用户再跑第二条命令。
回归:曾把「看结果」写成需要手工传 run_id 去执行一个导出脚本 ——
而 run_id 本来就在这条命令自己的输出里,前端也直接列出来。
"""
import inspect
from hdiv import cli
src = inspect.getsource(cli)
assert "_print_frontend_hint" in src
assert "回测记录" in src, "提示里要给出前端位置"
# daily 与 single 两条路径都必须给出该提示
assert src.count('_print_frontend_hint(res["run_id"])') >= 2
class TestSpeedLevers:
"""速度旋钮:`--every-n-days` / `--signal-every-n-days`。
两个频率**只改变「多久看一次」,不改变判定规则**。这里锁定三件事:
参数真的传到了执行器、缓存键把重建频率算进去、耗时模型随频率正确下降。
"""
def test_cli_flags_exist_and_parse(self) -> None:
from hdiv.cli import build_parser
p = build_parser()
a = p.parse_args(["backtest"])
assert a.every_n_days is None and a.signal_every_n_days is None, (
"默认必须是 None(= 用 config 里的值,通常为 1)"
)
a = p.parse_args(["backtest", "--mode", "daily", "--start", "2020-01-05",
"--every-n-days", "5", "--signal-every-n-days", "10"])
assert a.every_n_days == 5 and a.signal_every_n_days == 10
def test_runner_accepts_overrides(self) -> None:
from hdiv.backtest.daily import DailyRunner
import inspect
sig = inspect.signature(DailyRunner.run)
assert "every_n_days" in sig.parameters
assert "signal_every_n_days" in sig.parameters
assert sig.parameters["every_n_days"].default is None
assert sig.parameters["signal_every_n_days"].default is None
def test_cache_key_depends_on_pool_cadence(self) -> None:
"""1 日 / 5 日 筛出的池子是不同的输入,不能共用缓存。"""
from hdiv.backtest.daily import DailyRunner
from hdiv.strategy.registry import StrategyRegistry
reg = StrategyRegistry()
s = reg.load("config/strategy/high_dividend_v1.yml")
class _Stub:
"""只借 `_pools_cache_key`,不构造真 runner(那会连库)。"""
strategy = s
registry = reg
_pools_cache_key = DailyRunner._pools_cache_key
stub = _Stub()
k1 = stub._pools_cache_key(date(2020, 1, 5), date(2026, 9, 30), 1)
k5 = stub._pools_cache_key(date(2020, 1, 5), date(2026, 9, 30), 5)
assert k1 != k5, "不同重建频率必须落在不同缓存文件"
assert k1 == stub._pools_cache_key(date(2020, 1, 5), date(2026, 9, 30), 1), (
"同样的输入必须得到同样的键(缓存要可复用)"
)
def test_time_model_shrinks_with_coarser_cadence(self) -> None:
"""耗时模型必须随频率下降(否则预计时长会骗人)。"""
import hdiv.backtest.daily as D
def sim_seconds(days: int, signal_step: int) -> float:
return (days * D._SEC_PER_SIM_DAY_BASE
+ days / signal_step * D._SEC_PER_SIM_SIGNAL)
assert sim_seconds(1635, 1) > sim_seconds(1635, 5) > sim_seconds(1635, 21)
# 逐日口径必须与实测同量级(43 日实测 131 秒)
assert 100 < sim_seconds(43, 1) < 170, sim_seconds(43, 1)
# 每 5 日口径也必须与实测同量级(约 28 秒)
assert 15 < sim_seconds(43, 5) < 60, sim_seconds(43, 5)
# ---------------------------------------------------------------------------
# 性能优化的等价性(P1/P2/P4/P6)
# ---------------------------------------------------------------------------
def test_ttm_dps_series_prefix_equals_full() -> None:
"""P2 的前提:``ttm_dps_series`` 每一天的取值只依赖该日期与事件。
若这条不成立,「整段算一次再切片」就会与「每次按前缀重算」不同 ——
P2(消除 O(交易日数²))正是建立在这条性质上。
"""
from hdiv.factor.dividend_yield import ttm_dps_series
idx = pd.bdate_range("2020-01-01", periods=800)
# 事件间隔刻意覆盖三种情况:年内多次、略小于一年(重叠虚高)、略大于一年(断档虚低)
ex = [idx[50], idx[300], idx[520], idx[640], idx[770]]
ev = pd.DataFrame({
"ex_date": ex,
"imp_ann_date": [e - pd.Timedelta(days=12) for e in ex],
"cash_div_tax": [0.5, 0.3, 0.6, 0.55, 0.7],
})
full = ttm_dps_series(idx, ev, ttm_days=365, grace_days=45, smooth_spikes=True)
for i in (60, 310, 530, 650, 700, 799):
pref = ttm_dps_series(idx[: i + 1], ev, ttm_days=365, grace_days=45,
smooth_spikes=True)
assert abs(float(full[i]) - float(pref[-1])) < 1e-12, (
f"第 {i} 天:整段 {full[i]} != 前缀末值 {pref[-1]} —— P2 的前提不成立"
)
@pytest.mark.db
def test_pit_dividend_scope_is_exact_subset() -> None:
"""P4:``restrict_to`` 只能把 ``dividend_records`` 收窄成精确子集。"""
asof = date(2024, 6, 3)
pit = _load_pit(date(2024, 1, 1), date(2024, 6, 28), ref_end=date(2024, 12, 31))
full = pit.dividend_records(asof, years_back=8)
assert not full.empty
syms = sorted(full["symbol"].astype(str).unique())[:5]
pit.restrict_to(syms)
try:
scoped = pit.dividend_records(asof, years_back=8)
finally:
pit.restrict_to(None)
cols = list(full.columns)
want = full[full["symbol"].isin(set(syms))].reset_index(drop=True)
pd.testing.assert_frame_equal(
scoped.sort_values(cols, ignore_index=True),
want.sort_values(cols, ignore_index=True),
check_dtype=False, rtol=1e-9,
)
@pytest.mark.db
def test_pit_dividend_events_matches_direct_repo(plain_repo) -> None:
"""P6:内存版 ``dividend_events`` 必须与直连逐值一致(含 NULL 公告日边界)。"""
ref_end = date(2026, 9, 30)
pit = _load_pit(date(2025, 1, 1), ref_end, ref_end=ref_end)
for s, e in ((date(2024, 1, 1), date(2024, 12, 31)),
(date(2020, 1, 1), ref_end)):
a = pit.dividend_events(s, e)
b = plain_repo.dividend_events(s, e)
cols = list(a.columns)
pd.testing.assert_frame_equal(
a.sort_values(cols, ignore_index=True),
b.sort_values(cols, ignore_index=True),
check_dtype=False, rtol=1e-9,
obj=f"dividend_events {s}..{e}",
)
# 超出参照终点的区间必须退回直连,而不是返回少行的表
narrow = _load_pit(date(2025, 1, 1), date(2024, 12, 31),
ref_end=date(2024, 6, 3))
a = narrow.dividend_events(date(2024, 1, 1), date(2024, 12, 31))
b = plain_repo.dividend_events(date(2024, 1, 1), date(2024, 12, 31))
assert len(a) == len(b), "超出参照终点的区间被静默截断了"
@pytest.mark.db
def test_profile_prepare_accepts_external_price(plain_repo) -> None:
"""P6:引擎把已取好的价格交给画像复用,结果必须逐值不变。"""
from hdiv.profile.pit import PitProfileService
syms = ["600036.SH", "000651.SZ", "601398.SH"]
a, b = date(2015, 1, 1), date(2026, 9, 30)
pit = _load_pit(a, b, ref_end=b)
s1 = PitProfileService(window_years=5, repo=pit)
s1.prepare(syms, a, b)
price = plain_repo.price_history(syms, a, b, adjust="none")
s2 = PitProfileService(window_years=5, repo=pit)
s2.prepare(syms, a, b, price=price)
s1.configure({"dv_yield", "pe_ttm", "pb", "roe_avg"})
s2.configure({"dv_yield", "pe_ttm", "pb", "roe_avg"})
for d in (date(2018, 5, 18), date(2024, 6, 3)):
for y in syms:
x, z = s1.snapshot(y, d), s2.snapshot(y, d)
assert (x is None) == (z is None)
if x is not None and z is not None:
assert x.values == z.values and x.percentiles == z.percentiles
@pytest.mark.db
def test_dividend_scope_does_not_change_selection(pit_2024) -> None:
"""P4 的端到端保证:收窄分红取数不改变每日入选成员。"""
from hdiv.universe.pit import PitRepo
from hdiv.universe.selector import UniverseSelector
pit = pit_2024
cfg = load_config("universe")
sel = UniverseSelector(cfg, repo=pit)
days = [d for d in pit.trading_days(date(2024, 1, 1), date(2024, 6, 28))
if d in (date(2024, 1, 2), date(2024, 3, 1), date(2024, 6, 3))]
assert days
orig = PitRepo.dividend_records
def _unscoped(self, asof, **kw): # noqa: ANN001
saved = self._scope
self._scope = None
try:
return orig(self, asof, **kw)
finally:
self._scope = saved
# 关掉收窄(= 优化前的行为)
PitRepo.dividend_records = _unscoped
try:
before = {d: set(sel.run(asof=d, persist=False, verbose=False)["selected"]["symbol"])
for d in days}
finally:
PitRepo.dividend_records = orig
# 打开收窄:走生产路径(on_stage 钩子会按阶段收窄)
pit.set_candidate_scope(None)
screener = DailyUniverseScreener(cfg, pit, verbose=False)
after = {d: set(screener.screen_day(d).symbols) for d in days}
for d in days:
assert before[d] == after[d], (
f"{d} 收窄分红取数改变了入选:多 {sorted(after[d] - before[d])} "
f"少 {sorted(before[d] - after[d])}"
)
class TestPoolExitSemantics:
"""动态池的语义核心:掉出当日池子的持仓怎么办。
用合成行情直接调 ``_evaluate``,不经数据库 —— 这样 hold / sell 两条分支
都被真正执行到,而不是只靠「源码里有这个词」。
"""
@staticmethod
def _setup(pool_exit_action: str):
import pandas as pd
from hdiv.backtest.engine import BacktestEngine, Position
from hdiv.core.config import load_config as _lc
from hdiv.strategy.registry import StrategyRegistry
reg = StrategyRegistry()
s = reg.load("config/strategy/high_dividend_v1.yml").model_copy(deep=True)
# 闸门关闭:本用例考的是**池子语义**,不是画像闸门。
s.entry.profile_gate.enabled = False
bt = _lc("backtest")
eng = BacktestEngine(
s, backtest=bt, pool_exit_action=pool_exit_action,
signal_frequency_days=1,
)
# 400 个交易日的合成行情:收盘价从 40 线性跌到 10 → 股息率一路上行,
# 当前值必然是窗口内的最高分位(≈100%),足以触发最高买入档。
idx = pd.bdate_range(end="2024-06-28", periods=400)
close = np.linspace(40.0, 10.0, len(idx))
px = pd.DataFrame({"open": close, "close": close}, index=idx)
day = idx[-1].date()
ex = idx[200]
events = {"X.SH": pd.DataFrame([{
"ex_date": ex, "imp_ann_date": ex - pd.Timedelta(days=10),
"cash_div_tax": 1.0,
}])}
px_by_sym = {"X.SH": px}
ctx = {"px_by_sym": px_by_sym, "events": events,
"yield_by_sym": build_yield_series(px_by_sym, events)}
pos = {"X.SH": Position(symbol="X.SH", quantity=1000.0, avg_cost=20.0,
first_buy_date=day - timedelta(days=100),
last_buy_date=day - timedelta(days=100),
cost_basis=20000.0)}
return eng, day, ctx, pos
def test_high_percentile_triggers_buy_when_not_held(self) -> None:
eng, day, ctx, _ = self._setup("hold")
sigs = eng._evaluate(day, 1e6, {}, {"X.SH"}, ctx)
assert [x.kind for x in sigs] == ["BUY"], sigs
assert sigs[0].reason["in_universe"] is True
def test_in_pool_holding_can_add(self) -> None:
eng, day, ctx, pos = self._setup("hold")
sigs = eng._evaluate(day, 1e6, pos, {"X.SH"}, ctx)
assert [x.kind for x in sigs] == ["ADD"], sigs
def test_out_of_pool_holding_blocks_add_but_not_liquidate(self) -> None:
"""``hold``:掉出池子 → 停止加仓,但**不清仓**(留 HOLD 记录可追溯)。"""
eng, day, ctx, pos = self._setup("hold")
sigs = eng._evaluate(day, 1e6, pos, set(), ctx)
assert [x.kind for x in sigs] == ["HOLD"], sigs
assert sigs[0].reason["skip_reason"] == "OUT_OF_UNIVERSE"
assert sigs[0].reason["executed"] is False
assert sigs[0].reason["in_universe"] is False
# 不进入待成交队列 ⇒ 不会被 _execute 清算
assert sigs[0].kind not in {"BUY", "ADD", "SELL", "TRIM"}
assert pos["X.SH"].quantity == 1000.0
def test_out_of_pool_holding_liquidates_when_configured(self) -> None:
"""``sell``:掉出池子即清仓(可选项,不是默认)。"""
eng, day, ctx, pos = self._setup("sell")
sigs = eng._evaluate(day, 1e6, pos, set(), ctx)
assert [x.kind for x in sigs] == ["SELL"], sigs
assert sigs[0].target_weight == 0.0
def test_pool_exit_sell_branch_is_exercised_by_both_actions(self) -> None:
"""两种配置必须给出**不同**的动作,否则 pool_exit_action 是死配置。"""
a, day, ctx, pos_a = self._setup("hold")
b, _, ctx_b, pos_b = self._setup("sell")
ka = [x.kind for x in a._evaluate(day, 1e6, pos_a, set(), ctx)]
kb = [x.kind for x in b._evaluate(day, 1e6, pos_b, set(), ctx_b)]
assert ka != kb, f"pool_exit_action 未生效:{ka} == {kb}"
class TestMemberRows:
def _screen(self, symbols: list[str]) -> object:
from hdiv.universe.daily import ScreenDay
m = pd.DataFrame({
"symbol": symbols,
"name": ["A", "B", "C"][: len(symbols)],
"industry": ["银行"] * len(symbols),
"dividend_yield": [0.06, 0.05, 0.04][: len(symbols)],
"total_mv": [1e11, 2e11, 3e11][: len(symbols)],
"roe_avg": [0.12, 0.11, 0.10][: len(symbols)],
})
return ScreenDay(
trade_date=date(2024, 3, 1),
candidate_count=5000,
member_count=len(symbols),
symbols=symbols,
members=m,
stats={"market": 10},
)
def test_rows_shape(self) -> None:
from datetime import datetime
from hdiv.universe.daily import DailyUniverseScreener
rows = DailyUniverseScreener.member_rows(
"run1", [self._screen(["600036.SH", "601398.SH"])],
created_at=datetime(2024, 3, 1, 15, 0, 0),
)
assert len(rows) == 2
r = rows[0]
assert r["run_id"] == "run1"
assert r["trade_date"] == date(2024, 3, 1)
assert r["symbol"] == "600036.SH"
assert r["candidate_count"] == 5000
assert r["dividend_yield"] == pytest.approx(0.06)
# values_json 必须是可解析的 JSON(供「为什么是这只」复核)
import json
v = json.loads(r["values_json"])
assert v["dividend_yield"] == pytest.approx(0.06)
assert v["roe_avg"] == pytest.approx(0.12)
def test_empty_members_yield_no_rows(self) -> None:
from datetime import datetime
from hdiv.universe.daily import DailyUniverseScreener, ScreenDay
s = ScreenDay(date(2024, 3, 1), 5000, 0, [], pd.DataFrame())
assert DailyUniverseScreener.member_rows(
"r", [s], created_at=datetime(2024, 3, 1)
) == []
# ---------------------------------------------------------------------------
# DB:PitRepo 与 Repo 逐值一致
# ---------------------------------------------------------------------------
def _load_pit(start: date, end: date, ref_end: date | None = None):
"""载入一个 PitRepo(库不可用时 skip)。
**载入很贵**(参照数据约 17 秒 + 区块约 25 秒),因此这些用例共享
module 级 fixture,而不是各自新建一个 —— 否则整个文件的耗时是分钟级的倍数。
"""
from hdiv.universe.pit import PitRepo
try:
pit = PitRepo()
pit.load_reference(end=ref_end or end)
pit.load_range(start, end)
except Exception as exc: # pragma: no cover - 环境相关
pytest.skip(f"数据库不可用:{exc}")
return pit
#: 等价性 / 剪枝用例共用的区间(覆盖 2024 上半年,含年报季)
_RANGE_START, _RANGE_END = date(2024, 1, 1), date(2024, 6, 28)
_ASOF = date(2024, 6, 3)
@pytest.fixture(scope="module")
def pit_2024():
"""共享的 PitRepo(2024 上半年)。用例只读,唯一例外是剪枝用例会改候选范围。"""
from hdiv.universe.pit import PitRepo
try:
pit = PitRepo()
pit.load_reference(end=date(2024, 12, 31))
pit.load_range(_RANGE_START, _RANGE_END)
except Exception as exc: # pragma: no cover
pytest.skip(f"数据库不可用:{exc}")
yield pit
pit.release_range()
@pytest.fixture(scope="module")
def plain_repo():
from hdiv.data.repo import Repo
try:
repo = Repo()
repo.trading_day(_ASOF) # 探活
except Exception as exc: # pragma: no cover
pytest.skip(f"数据库不可用:{exc}")
return repo
def _assert_same_multiset(name: str, a: pd.DataFrame, b: pd.DataFrame) -> None:
"""按**全列排序**后逐值比较(行序不同不代表内容不同)。
用全部列排序而不是挑几列:财务表里同一 (symbol, 财年) 可能有多条重述公告,
只按业务键排序会让等值行两两错配,产生假报警。
"""
assert list(a.columns) == list(b.columns), (
f"{name} 列集合不一致:{list(a.columns)} vs {list(b.columns)}"
)
if a.empty and b.empty:
return
cols = list(a.columns)
x = a.sort_values(cols, ignore_index=True)
y = b.sort_values(cols, ignore_index=True)
assert x.shape == y.shape, f"{name} 行数不一致:{x.shape} vs {y.shape}"
pd.testing.assert_frame_equal(
x, y, check_dtype=False, rtol=1e-9, atol=1e-12,
obj=f"{name} 与直连口径不一致",
)
@pytest.mark.db
def test_pit_repo_matches_direct_repo(pit_2024, plain_repo) -> None:
"""PitRepo 的每一个被覆盖的方法都必须与直连 Repo 逐值一致。"""
asof = _ASOF
pit, plain = pit_2024, plain_repo
_assert_same_multiset(
"market_panel(lookback=5)",
pit.market_panel(asof, lookback_days=5),
plain.market_panel(asof, lookback_days=5),
)
_assert_same_multiset(
"market_panel(lookback=0)",
pit.market_panel(asof),
plain.market_panel(asof),
)
_assert_same_multiset(
"avg_amount(20)",
pit.avg_amount(asof, window=20),
plain.avg_amount(asof, window=20),
)
syms = ["600036.SH", "000651.SZ"]
_assert_same_multiset(
"avg_amount(20, symbols)",
pit.avg_amount(asof, window=20, symbols=syms),
plain.avg_amount(asof, window=20, symbols=syms),
)
_assert_same_multiset(
"financial_panel",
pit.financial_panel(asof),
plain.financial_panel(asof),
)
_assert_same_multiset(
"annual_financial_history(5)",
pit.annual_financial_history(asof, years=5),
plain.annual_financial_history(asof, years=5),
)
_assert_same_multiset(
"annual_financial_history(11)",
pit.annual_financial_history(asof, years=11),
plain.annual_financial_history(asof, years=11),
)
_assert_same_multiset(
"annual_financial_averages(5)",
pit.annual_financial_averages(asof, years=5),
plain.annual_financial_averages(asof, years=5),
)
_assert_same_multiset(
"annual_financials(10)",
pit.annual_financials(asof, years=10),
plain.annual_financials(asof, years=10),
)
_assert_same_multiset(
"dividend_records(8)",
pit.dividend_records(asof, years_back=8),
plain.dividend_records(asof, years_back=8),
)
_assert_same_multiset(
"dividend_records(all)",
pit.dividend_records(asof, years_back=8, implemented_only=False),
plain.dividend_records(asof, years_back=8, implemented_only=False),
)
assert pit.trading_day(asof) == plain.trading_day(asof)
assert pit.prev_trading_day(asof) == plain.prev_trading_day(asof)
assert len(pit.trading_days(date(2024, 1, 1), asof)) == len(
plain.trading_days(date(2024, 1, 1), asof)
)
assert pit.suspended_on(asof) == plain.suspended_on(asof)
assert pit.st_symbols(asof) == plain.st_symbols(asof)
@pytest.mark.db
def test_pit_repo_symbols_subset_is_exact(pit_2024, plain_repo) -> None:
"""``symbols`` 过滤只是全市场结果取子集:两者必须一致(含缓存命中路径)。"""
asof, pit, plain = _ASOF, pit_2024, plain_repo
syms = ["600036.SH", "601398.SH", "000651.SZ"]
full = pit.financial_panel(asof)
sub = pit.financial_panel(asof, symbols=syms)
_assert_same_multiset(
"financial_panel(symbols) 子集",
sub, full[full["symbol"].isin(syms)].reset_index(drop=True),
)
_assert_same_multiset(
"financial_panel(symbols) vs 直连",
sub, plain.financial_panel(asof, symbols=syms),
)
@pytest.mark.db
def test_pit_repo_caches_are_exact() -> None:
"""可见性缓存必须**精确**:命中与不命中给出同一结果。
缓存键是「已公告财报条数」。这里把缓存清空强制重算一次,与命中结果比对 ——
若不相等,说明键不是充分统计量(那就会静默用错的面板做决策)。
"""
asof = date(2024, 4, 25) # 年报季,可见集合天天变
pit = _load_pit(date(2024, 3, 1), date(2024, 6, 28), ref_end=date(2024, 12, 31))
hit = pit.financial_panel(asof)
pit._memo.clear()
cold = pit.financial_panel(asof)
_assert_same_multiset("financial_panel 缓存命中 vs 冷算", hit, cold)
h1 = pit.annual_financial_history(asof, years=11)
pit._memo.clear()
h2 = pit.annual_financial_history(asof, years=11)
_assert_same_multiset("annual_financial_history 命中 vs 冷算", h1, h2)
@pytest.mark.db
def test_pit_repo_out_of_range_raises() -> None:
"""区间外必须**报错**,不能返回空表 —— 空表会被当成「当天没有股票」。"""
from hdiv.core.errors import HdivError
pit = _load_pit(date(2024, 5, 1), date(2024, 6, 28))
with pytest.raises(HdivError):
pit.market_panel(date(2019, 1, 2), lookback_days=5)
with pytest.raises(HdivError):
pit.avg_amount(date(2019, 1, 2), window=20) # 全市场查询必须报错
@pytest.mark.db
def test_pit_repo_avg_amount_falls_back_for_narrow_query(plain_repo) -> None:
"""区块释放后,**带 symbol 的**均额查询仍可回答(画像路径依赖它)。"""
pit = _load_pit(date(2024, 5, 1), date(2024, 6, 28))
asof = date(2024, 6, 3)
pit.release_range()
got = pit.avg_amount(asof, window=20, symbols=["600036.SH"])
want = plain_repo.avg_amount(asof, window=20, symbols=["600036.SH"])
_assert_same_multiset("avg_amount 窄查询回退", got, want)
# ---------------------------------------------------------------------------
# DB:预剪枝等价性
# ---------------------------------------------------------------------------
@pytest.mark.db
def test_prune_does_not_change_selection(pit_2024) -> None:
"""开/关预剪枝必须选出完全相同的成员(否则它是近似,不是等价)。"""
from hdiv.universe.selector import UniverseSelector
pit = pit_2024
start, end = _RANGE_START, _RANGE_END
cfg = load_config("universe")
sel = UniverseSelector(cfg, repo=pit)
# 几天样本:月初、季报期、年报季、月末
wanted = {date(2024, 1, 2), date(2024, 3, 1), date(2024, 4, 25),
date(2024, 6, 3), date(2024, 6, 28)}
days = [d for d in pit.trading_days(start, end) if d in wanted]
assert days, "样本交易日为空"
# 先不剪枝
pit.set_candidate_scope(None)
base = {d: set(sel.run(asof=d, persist=False, verbose=False)["selected"]["symbol"])
for d in days}
# 再剪枝
DailyUniverseScreener(cfg, pit, verbose=False).build_prune_set(start, end)
pruned = {d: set(sel.run(asof=d, persist=False, verbose=False)["selected"]["symbol"])
for d in days}
pit.set_candidate_scope(None) # 复位,避免影响其它用例
for d in days:
assert base[d] == pruned[d], (
f"{d} 预剪枝改变了最终入选:"
f"多出 {sorted(pruned[d] - base[d])},丢失 {sorted(base[d] - pruned[d])}"
)
@pytest.mark.db
def test_prune_set_keeps_every_actual_member(pit_2024) -> None:
"""预剪枝的保留集合必须**包含**每天实际选出的成员(上界论证的实证)。"""
from hdiv.universe.selector import UniverseSelector
pit = pit_2024
start, end = _RANGE_START, _RANGE_END
cfg = load_config("universe")
allowed = DailyUniverseScreener(cfg, pit, verbose=False).build_prune_set(start, end)
pit.set_candidate_scope(None) # 不剪枝地真筛一次
sel = UniverseSelector(cfg, repo=pit)
for d in [date(2024, 3, 1), date(2024, 6, 3)]:
got = set(sel.run(asof=d, persist=False, verbose=False)["selected"]["symbol"])
assert got <= allowed, f"{d} 有成员被预剪枝误剔:{sorted(got - allowed)}"
# ---------------------------------------------------------------------------
# DB:PIT 纪律 —— 股票池不随「回测终点」变化
# ---------------------------------------------------------------------------
@pytest.mark.db
def test_daily_pool_is_point_in_time() -> None:
"""某一天的股票池只依赖该日及之前的数据,与回测终点无关。
这是「无未来函数」在每日选股上的直接检验:把参照数据的终点推后一年,
同一天的选股结果必须**逐只相同**。
"""
from hdiv.universe.selector import UniverseSelector
asof = date(2023, 6, 1)
# 只载入一个月,控制成本(这条检验比的是「两个终点是否给出同一答案」)
a = _load_pit(date(2023, 5, 1), asof, ref_end=asof)
b = _load_pit(date(2023, 5, 1), asof, ref_end=asof + timedelta(days=365))
cfg = load_config("universe")
ra = UniverseSelector(cfg, repo=a).run(asof=asof, persist=False, verbose=False)
rb = UniverseSelector(cfg, repo=b).run(asof=asof, persist=False, verbose=False)
assert set(ra["selected"]["symbol"]) == set(rb["selected"]["symbol"])
# ---------------------------------------------------------------------------
# DB:端到端(短区间)
# ---------------------------------------------------------------------------
# 回测 run 的从属表(顺序 = 删除顺序:先子后父)
_BACKTEST_CHILD_TABLES = (
"hd_daily_universe",
"hd_backtest_equity",
"hd_backtest_metric",
"hd_backtest_position",
"hd_backtest_signal",
"hd_backtest_trade",
)
def _purge_daily_run(run_id: str) -> None:
"""物理删除本次端到端测试自己写入的 run 及其从属行。
**测试不得在分析库里留垃圾**:每跑一次都会多出一条同名回测记录(run_id 指纹
含 ``datetime.now()``,见 ``hdiv.backtest.engine``),跑几十次后前端「回测
记录」就被测试产物淹掉,而它对外看起来和真实回测没有区别。
项目的 ``StatementGuard`` 有意禁止 DELETE(只增不删),所以这里自建一个
**不装守卫**的连接,并严格按 ``run_id`` 精确回收刚刚写入的行。
"""
from sqlalchemy import create_engine, text
from hdiv.data import db
cfg = load_config("datasource")
db.load_dotenv_once()
engine = create_engine(db.build_url(cfg), pool_pre_ping=True, future=True)
try:
with engine.begin() as conn:
for table in (*_BACKTEST_CHILD_TABLES, "hd_backtest_run"):
conn.execute(text(f"DELETE FROM {table} WHERE run_id = :r"),
{"r": run_id})
finally:
engine.dispose()
@pytest.mark.db
def test_daily_run_persists_members_and_run() -> None:
"""daily 端到端:落库 run(mode=daily)与每日选股成员(跑完自清理)。"""
from hdiv.backtest.daily import DailyRunner
from hdiv.data import db
try:
runner = DailyRunner.from_strategy("config/strategy/high_dividend_v1.yml")
except Exception as exc: # pragma: no cover
pytest.skip(f"数据库不可用:{exc}")
start, end = date(2024, 3, 1), date(2024, 3, 15)
try:
res = runner.run(start=start, end=end, persist=True, verbose=False)
except Exception as exc: # pragma: no cover
pytest.skip(f"数据不足,跳过端到端:{exc}")
run_id = res["run_id"]
try:
assert res["mode"] == "daily"
cfg = load_config("datasource")
assert db.table_exists("hd_daily_universe", cfg)
got = db.read_sql(
"SELECT COUNT(*) AS n, COUNT(DISTINCT trade_date) AS d "
"FROM hd_daily_universe WHERE run_id = :r",
{"r": run_id}, cfg=cfg,
)
assert int(got["n"].iloc[0]) > 0, "未落库任何每日选股成员"
assert int(got["d"].iloc[0]) >= 5, "落库的决策时点太少"
run = db.read_sql(
"SELECT mode, universe_run_id FROM hd_backtest_run WHERE run_id = :r",
{"r": run_id}, cfg=cfg,
)
assert len(run) == 1
assert run["mode"].iloc[0] == "daily"
# daily 是动态池:不得关联任何冻结股票池
assert pd.isna(run["universe_run_id"].iloc[0]) or run["universe_run_id"].iloc[0] is None
finally:
# 无论断言成功还是失败,都不能把测试产物留在分析库里
_purge_daily_run(run_id)