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

442 lines
17 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.
"""CLI 接口契约测试。
**为什么需要**:使用手册(``docs/user-guide.md``)里的命令就是用户看到的接口。
文档与实现脱节是极易发生且很难察觉的问题 —— 实测发现手册记载的
``hdiv sync financial --interleaved`` 在 CLI 中根本没有暴露,
而 ``hdiv ddl verify`` 的输出格式也与手册不符。
本测试把「手册承诺的接口」变成可执行断言:
只要有人删掉一个参数或改掉命令名,测试立刻失败。
"""
from __future__ import annotations
import pandas as pd
import pytest
from hdiv.cli import build_parser
@pytest.fixture(scope="module")
def parser():
return build_parser()
def _subparsers(p):
"""取子命令表。同时支持两种声明方式:
- ``add_subparsers()`` → choices 是 dict(sync/universe/...)
- ``add_argument("action", choices=[...])`` → choices 是 list(ddl/report/strategy)
"""
for action in p._actions:
ch = getattr(action, "choices", None)
if not ch:
continue
if isinstance(ch, dict):
return ch
raise AssertionError("未找到子命令")
def _action_choices(p) -> set[str]:
"""取位置参数(如 ddl 的 plan/apply/verify)的取值集合。"""
for action in p._actions:
ch = getattr(action, "choices", None)
if isinstance(ch, (list, tuple, set)) and not action.option_strings:
return set(ch)
return set()
def _opts(p) -> set[str]:
out: set[str] = set()
for a in p._actions:
out.update(a.option_strings)
return out
# ---------------------------------------------------------------------------
# 手册记载的顶层命令必须全部存在
# ---------------------------------------------------------------------------
DOCUMENTED_COMMANDS = [
"ddl", "sync", "audit", "report", "universe",
"profile", "strategy", "backtest", "sensitivity",
"web", "site",
]
def test_all_documented_commands_exist(parser) -> None:
subs = _subparsers(parser)
missing = [c for c in DOCUMENTED_COMMANDS if c not in subs]
assert not missing, f"手册记载但 CLI 不存在的命令:{missing}"
def test_no_undocumented_commands(parser) -> None:
"""反向检查:CLI 不应有手册未提及的命令。"""
subs = set(_subparsers(parser))
extra = subs - set(DOCUMENTED_COMMANDS)
assert not extra, f"CLI 存在手册未记载的命令:{sorted(extra)}"
# ---------------------------------------------------------------------------
# 手册记载的动作/参数必须存在
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"cmd,expected",
[
("ddl", {"plan", "apply", "verify"}),
("report", {"index", "validate"}),
("strategy", {"validate", "register", "list", "diff"}),
(
"sync",
{"daily", "dividend", "financial", "index", "price", "trading", "backfill"},
),
("site", {"normalize", "archive", "build", "status"}),
],
)
def test_documented_actions_exist(parser, cmd: str, expected: set[str]) -> None:
sub = _subparsers(parser)[cmd]
actual = _action_choices(sub)
if not actual: # 用 add_subparsers 声明的命令
actual = set(_subparsers(sub))
missing = expected - actual
assert not missing, f"hdiv {cmd} 缺少手册记载的动作:{sorted(missing)}"
@pytest.mark.parametrize(
"cmd,flags",
[
("sync", {"--only-missing", "--limit", "--symbols", "--apis",
"--interleaved", "--start", "--end", "--no-resume", "--no-weight",
"--basic-start", "--basic-end",
# sync daily(手册 §5.2.1)
"--dry-run", "--asof", "--lookback-days", "--only",
"--no-financial", "--financial-limit", "--json"}),
("universe", {"-c", "--config", "--asof", "--no-persist", "--no-html"}),
("profile", {"--universe-run", "--symbols", "--asof", "--html-limit"}),
("backtest", {"-s", "--strategy", "--mode", "--start", "--end", "--universe-run",
"--allow-lookahead-universe"}),
("sensitivity", {"-s", "--strategy", "--sweep"}),
("strategy", {"-f", "--file", "--other"}),
("audit", {"--no-persist", "--no-html"}),
("report", {"--dir"}),
("web", {"--host", "--port", "--api-only", "--out-dir"}),
],
)
def test_documented_flags_exist(parser, cmd: str, flags: set[str]) -> None:
sub = _subparsers(parser)[cmd]
have = _opts(sub)
missing = flags - have
assert not missing, f"hdiv {cmd} 缺少手册记载的参数:{sorted(missing)}"
def test_sync_interleaved_is_a_real_flag(parser) -> None:
"""回归:手册要求 ``hdiv sync financial --interleaved``,CLI 必须真的支持。"""
sub = _subparsers(parser)["sync"]
assert "--interleaved" in _opts(sub)
# 且必须真的能解析(不只是声明)
args = parser.parse_args(["sync", "financial", "--interleaved"])
assert args.interleaved is True
args2 = parser.parse_args(["sync", "financial"])
assert args2.interleaved is False
def test_sync_daily_defaults(parser) -> None:
"""手册 §5.2.1 承诺的 ``hdiv sync daily`` 必须存在,且默认是「真抓、含财报」。"""
args = parser.parse_args(["sync", "daily"])
assert args.dry_run is False, "默认必须真抓;--dry-run 是显式开关"
assert args.no_financial is False, "默认应包含财报四表(有上限兜底)"
assert args.financial_limit == 500
assert args.lookback_days == 45
assert args.only is None
assert args.asof is None
# --only 必须真的能解析
a2 = parser.parse_args(["sync", "daily", "--only", "price", "dividend"])
assert a2.only == ["price", "dividend"]
def test_backtest_universe_run_flag(parser) -> None:
"""股票池 ↔ 回测 的关联入口:``--universe-run`` 必须存在且可解析。"""
args = parser.parse_args(["backtest", "--universe-run", "abc123"])
assert args.universe_run == "abc123"
assert args.allow_lookahead_universe is False, "默认不得放行未来函数"
args2 = parser.parse_args(["backtest"])
assert args2.universe_run is None
def test_backtest_lookahead_override_flag(parser) -> None:
"""放行未来函数必须是一个显式开关,不能是默认行为。"""
a = parser.parse_args(
["backtest", "--universe-run", "abc123", "--allow-lookahead-universe"]
)
assert a.allow_lookahead_universe is True
@pytest.mark.db
def test_frozen_universe_is_used_when_run_id_given() -> None:
"""显式放行时,冻结股票池在各调仓日必须完全相同。"""
from datetime import date
from hdiv.backtest.engine import BacktestEngine
from hdiv.core.config import load_config
from hdiv.data import db
db.load_dotenv_once()
try:
cfg = load_config("datasource")
df = db.read_sql(
"SELECT run_id FROM hd_universe_run WHERE deleted_at IS NULL "
"ORDER BY created_at DESC LIMIT 1", cfg=cfg,
)
if df.empty:
pytest.skip("没有筛选记录")
rid = df["run_id"].iloc[0]
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
eng = BacktestEngine.from_strategy(
"config/strategy/high_dividend_v1.yml", universe_run_id=rid,
allow_lookahead_universe=True,
)
assert eng.universe_run_id == rid
ctx = eng._prepare(eng.repo.trading_days(date(2024, 1, 1), date(2024, 6, 28)),
verbose=False)
sets = list(ctx["universe_by_refresh"].values())
assert sets, "应产生股票池"
assert all(x == sets[0] for x in sets), "冻结股票池在各调仓日必须完全相同"
@pytest.mark.db
def test_future_universe_is_rejected_by_default() -> None:
"""回归:股票池 asof 晚于回测起点 = 未来函数,必须默认拒绝。
早期实现允许用 2025 年选出的股票池跑 2015 起的单次回测,2015-01-06
就按那份事后名单成交 —— 与 walk-forward 已明确拒绝的做法自相矛盾。
"""
from datetime import date
from hdiv.backtest.engine import BacktestEngine
from hdiv.core.config import load_config
from hdiv.core.errors import HdivError
from hdiv.data import db
db.load_dotenv_once()
try:
cfg = load_config("datasource")
df = db.read_sql(
"SELECT run_id, asof_date FROM hd_universe_run WHERE deleted_at IS NULL "
"ORDER BY asof_date DESC LIMIT 1", cfg=cfg,
)
if df.empty:
pytest.skip("没有筛选记录")
rid = df["run_id"].iloc[0]
uasof = pd.to_datetime(df["asof_date"].iloc[0]).date()
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
eng = BacktestEngine.from_strategy(
"config/strategy/high_dividend_v1.yml", universe_run_id=rid
)
early = date(uasof.year - 5, 1, 1)
with pytest.raises(HdivError) as ei:
eng._prepare(eng.repo.trading_days(early, date(uasof.year - 5, 3, 31)), verbose=False)
msg = str(ei.value)
assert "晚于回测起点" in msg
assert "--allow-lookahead-universe" in msg, "错误信息必须给出放行开关"
# 起点晚于股票池 asof 时是合法的:此时股票池属于「事前信息」
later = date(uasof.year + 1, 1, 2)
eng2 = BacktestEngine.from_strategy(
"config/strategy/high_dividend_v1.yml", universe_run_id=rid
)
days = eng2.repo.trading_days(later, date(later.year, 3, 31))
if len(days) >= 2:
ctx = eng2._prepare(days, verbose=False)
assert ctx["universe_by_refresh"], "asof 不晚于起点时应正常使用该股票池"
assert eng2.lookahead_universe_note is None
def test_backtest_mode_choices(parser) -> None:
"""手册承诺 single / walkforward / daily 三种模式。"""
sub = _subparsers(parser)["backtest"]
for a in sub._actions:
if "--mode" in a.option_strings:
assert set(a.choices) == {"single", "walkforward", "daily"}
return
raise AssertionError("backtest 缺少 --mode 参数")
def test_price_which_choices(parser) -> None:
sub = _subparsers(parser)["sync"]
for a in sub._actions:
if "--which" in a.option_strings:
assert set(a.choices) == {"daily", "adj_factor", "daily_basic"}
return
raise AssertionError("sync 缺少 --which 参数")
# ---------------------------------------------------------------------------
# 手册记载的输出格式
# ---------------------------------------------------------------------------
def test_ddl_verify_output_mentions_table_count() -> None:
"""手册写的是「OK:30 张 hd_* 表结构全部符合 schema 定义」,输出须含数量。"""
import inspect
from hdiv import cli
from hdiv.data.schema import ALL_TABLES
src = inspect.getsource(cli.cmd_ddl)
assert "len(ddl.ALL_TABLES)" in src, "ddl verify 输出应包含表的数量"
assert len(ALL_TABLES) == 31
def test_help_text_is_chinese(parser) -> None:
"""手册面向中文用户,所有命令都应有中文说明。"""
subs = _subparsers(parser)
for name in DOCUMENTED_COMMANDS:
assert subs[name].description, f"hdiv {name} 缺少说明文字"
# ---------------------------------------------------------------------------
# 手册文件本身
# ---------------------------------------------------------------------------
def test_user_guide_exists_and_covers_all_commands() -> None:
from hdiv.core.paths import project_root
guide = project_root() / "docs" / "user-guide.md"
assert guide.is_file(), "缺少使用手册 docs/user-guide.md"
text = guide.read_text(encoding="utf-8")
for cmd in DOCUMENTED_COMMANDS:
assert f"hdiv {cmd}" in text, f"手册未记载命令:hdiv {cmd}"
# 手册必须如实记录已知限制与部署要求(这两点最容易在文档里被淡化)
assert "已知限制" in text
assert "echarts.min.js" in text, "手册必须说明图表库的部署依赖"
assert "asset_mode" in text, "手册必须说明自包含模式的开关"
# 前后端分离的部署要点必须在手册里
assert "/api" in text, "手册必须说明 API 反代"
assert "nginx" in text, "手册必须给出 nginx 配置指引"
# ---------------------------------------------------------------------------
# 参数组合与错误呈现
# ---------------------------------------------------------------------------
# 只有这三个命令同时具备 --no-persist 与 --html(audit 传内存结果,两者可共存)
@pytest.mark.parametrize("cmd", ["universe", "backtest"])
def test_no_persist_with_html_is_rejected_clearly(cmd: str) -> None:
"""回归:--no-persist 与 --html 互相矛盾,必须给出可操作提示。
静态报告只从数据库读取(保证数字可追溯到 SQL),未落库的运行渲染不出来。
早期实现会崩在报告生成器的 ValueError 里,用户看不出是参数冲突。
"""
import subprocess
import sys
r = subprocess.run(
[sys.executable, "-m", "hdiv", cmd, "--no-persist", "--html"],
capture_output=True, text=True, env={**__import__("os").environ, "PYTHONPATH": "src"},
)
assert r.returncode == 1
out = r.stdout + r.stderr
assert "--no-persist 与 --html 不能同时使用" in out, out[:400]
# 必须是友好提示,而不是 traceback
assert "Traceback" not in out, "用户可理解的错误不应打印调用栈"
def test_audit_can_combine_no_persist_and_html() -> None:
"""audit 传的是内存结果而非按 run_id 读库,因此两者可以共存。"""
import inspect
from hdiv import cli
src = inspect.getsource(cli.cmd_audit)
assert "_reject_no_persist_with_html" not in src, \
"audit 不需拦截该组合(报告用内存 summary 渲染)"
def test_hdiv_error_is_caught_without_traceback() -> None:
"""main() 应优雅处理 HdivError(数据缺失/配置冲突等),不打印调用栈。"""
import inspect
from hdiv import cli
src = inspect.getsource(cli.main)
assert "except HdivError" in src, "main() 未捕获 HdivError"
branch = src.split("except HdivError")[1]
assert "print_exc" not in branch, "HdivError 分支不应打印调用栈"
assert "return 1" in branch, "应返回非零退出码"
def test_html_flag_exists_on_all_report_producing_commands() -> None:
"""所有会产出报告的命令都应有 --html 开关且默认关闭。"""
from hdiv.cli import build_parser
p = build_parser()
for cmd in ("universe", "profile", "backtest", "audit", "sensitivity"):
args = p.parse_args([cmd])
assert getattr(args, "html", None) is False, f"hdiv {cmd} --html 默认应为 False"
def test_walkforward_rejects_universe_run() -> None:
"""回归:--universe-run 与 walk-forward 时序不兼容,必须明确拒绝。
股票池自带 asof(如 2025-01-21),而 walk-forward 窗口从 2015 年就开始训练;
把未来时点选出的股票池套到更早的窗口上等于用未来信息选股。
早期实现没有该参数,于是 --universe-run 被**静默忽略** ——
用户以为按自己的股票池跑了,实际跑的是逐窗口自筛选。
"""
import os
import subprocess
import sys
r = subprocess.run(
[sys.executable, "-m", "hdiv", "backtest",
"--universe-run", "whatever", "--mode", "walkforward"],
capture_output=True, text=True,
env={**os.environ, "PYTHONPATH": "src"},
)
assert r.returncode == 1
out = r.stdout + r.stderr
assert "不支持 --universe-run" in out, out[:400]
assert "未来" in out, "应说明原因(未来函数)"
assert "Traceback" not in out
def test_walkforward_runner_has_no_universe_param() -> None:
"""再确认一层:runner 本身不接受冻结股票池,避免日后被误加回去。"""
import inspect
from hdiv.backtest.walk_forward import WalkForwardRunner
sig = inspect.signature(WalkForwardRunner.__init__)
assert "universe_run_id" not in sig.parameters, (
"WalkForwardRunner 不应接受 universe_run_id —— "
"冻结股票池与 walk-forward 的时序纪律冲突"
)
def test_no_future_warning_on_selector() -> None:
"""回归:object 列的 fillna 曾触发 pandas Downcasting FutureWarning。
用 -W error::FutureWarning 跑一遍筛选路径,确保不再产生该警告
(pandas 未来版本会改变行为,届时结果可能静默变化)。
"""
import os
import subprocess
import sys
r = subprocess.run(
[sys.executable, "-W", "error::FutureWarning", "-m", "hdiv",
"universe", "--asof", "2025-03-21", "--no-persist", "--no-html"],
capture_output=True, text=True,
env={**os.environ, "PYTHONPATH": "src"}, timeout=600,
)
assert "FutureWarning" not in (r.stdout + r.stderr), \
f"仍存在 FutureWarning:{(r.stdout + r.stderr)[-400:]}"