说明:本提交是工作区中此前的未提交工作(在 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 模式的主要性能瓶颈,建议下一轮优化。
442 lines
17 KiB
Python
442 lines
17 KiB
Python
"""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:]}"
|