Files
ggx/tests/test_web.py
T
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

950 lines
37 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.
"""Web 层测试:API 契约、软删除语义、站点归一化、路径重写。
**为什么要有契约测试**:前端(``web/app.js``)与后端(``web/server.py``)是
两个独立文件,改动其中一个很容易忘记另一个。这里把「前端会调用的接口」
写成断言 —— 只要后端删掉某个路由,测试立刻失败,而不是等到页面上报错。
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
from hdiv.core.paths import project_root
from hdiv.web import site
from hdiv.web.server import ROUTES
# ---------------------------------------------------------------------------
# 前端 ↔ 后端 接口契约
# ---------------------------------------------------------------------------
#: 前端实际会调用的接口(method, 路径样例)
#: 路径样例用于匹配路由正则;改前端时需同步此表。
FRONTEND_CALLS: list[tuple[str, str]] = [
("GET", "/api/health"),
("GET", "/api/config/display"),
("GET", "/api/summary"),
("GET", "/api/universes"),
("GET", "/api/universes/abc123"),
("GET", "/api/universes/abc123/members"),
("GET", "/api/universes/abc123/members/600519.SH"),
("GET", "/api/universes/abc123/backtests"),
("PATCH", "/api/universes/abc123"),
("GET", "/api/stocks/600519.SH"),
("GET", "/api/backtests"),
("GET", "/api/backtests/abc123"),
("GET", "/api/backtests/abc123/metrics"),
("GET", "/api/backtests/abc123/equity"),
("GET", "/api/backtests/abc123/trades"),
# 净值曲线右轴可叠加的基准指数
("GET", "/api/indices"),
("GET", "/api/backtests/abc123/signals"),
("PATCH", "/api/backtests/abc123"),
# 回测内分析:任意日持仓 + 个股买卖点
("GET", "/api/backtests/abc123/portfolio"),
("GET", "/api/backtests/abc123/position-dates"),
("GET", "/api/backtests/abc123/stocks"),
("GET", "/api/backtests/abc123/stocks/600519.SH"),
# Walk-forward 样本外
("GET", "/api/walkforwards"),
("GET", "/api/walkforwards/abc123"),
]
def _match(method: str, path: str) -> bool:
return any(m == method and pat.match(path) for m, pat, _ in ROUTES)
@pytest.mark.parametrize("method,path", FRONTEND_CALLS)
def test_frontend_api_calls_have_routes(method: str, path: str) -> None:
assert _match(method, path), f"前端会调用 {method} {path},但后端没有对应路由"
def test_app_js_only_calls_known_api_prefixes() -> None:
"""app.js 里出现的接口路径必须在契约表中有覆盖。"""
js = (project_root() / "web" / "app.js").read_text(encoding="utf-8")
# 抓取 api(`xxx`) / api('xxx') / patch(`xxx`, ...) 的首段
calls = re.findall(r"\b(?:api|patch)\(\s*[`'\"]([a-z][\w/\-${}.\[\]]*)", js)
assert calls, "未从 app.js 中解析出任何接口调用(解析逻辑需更新)"
# FRONTEND_CALLS 里是绝对路径 /api/xxx;app.js 里的参数是相对 api 基址的
# (api('summary') 实际请求 /api/summary),因此要去掉 api/ 前缀再比较。
known = {
p.removeprefix("/api/").strip("/").split("/")[0]
for _m, p in FRONTEND_CALLS
}
unknown = {
c.split("/")[0].split("?")[0]
for c in calls
if c.split("/")[0].split("?")[0] not in known
}
# 允许 ${...} 模板片段(这些是动态拼出的 run_id / symbol,前缀已在表中)
unknown = {u for u in unknown if not u.startswith("$")}
assert not unknown, f"app.js 调用了契约表未覆盖的接口前缀:{sorted(unknown)}"
def test_members_endpoint_defaults_to_selected() -> None:
"""接口层默认值:不传 passed 时应等价于 passed=1,而非全部候选。"""
import inspect
from hdiv.web import server
src = inspect.getsource(server._members)
assert 'passed_raw == "all"' in src or "passed_raw == 'all'" in src, \
"默认值应只把显式的 all 当作「全部候选」"
assert 'passed_raw != "0"' in src or "passed_raw != '0'" in src, \
"未指定 passed 时应视为 1(仅入选)"
def test_every_route_is_covered_by_the_contract() -> None:
"""反向检查:每条后端路由都必须出现在契约表里。
这样契约表就是「前后端接口清单」的唯一事实来源:
新增接口忘了登记会被发现,删接口忘了清契约也会被发现。
早期版本给两个「可选下钻」接口开了后门(阈值 <= 2),
结果新增的三个接口漏登记却被放行 —— 所以现在零容忍。
"""
known = {p for _m, p in FRONTEND_CALLS}
uncovered = []
for method, pat, _fn in ROUTES:
if not any(m == method and pat.match(p) for m, p in FRONTEND_CALLS) and \
not any(pat.match(p) for p in known):
uncovered.append((method, pat.pattern))
assert not uncovered, (
f"以下路由未登记在 FRONTEND_CALLS 中:{uncovered}\n"
"新增接口时请同步更新契约表,否则前端改动无法被发现。"
)
# ---------------------------------------------------------------------------
# 资源路径重写(归档后图表不能失效)
# ---------------------------------------------------------------------------
def test_rewrite_asset_paths_depth0() -> None:
html = '<script src="assets/echarts.min.js"></script>'
assert site.rewrite_asset_paths(html, 0) == html
def test_rewrite_asset_paths_depth2() -> None:
html = '<script src="assets/echarts.min.js"></script>'
out = site.rewrite_asset_paths(html, 2)
assert 'src="../../assets/echarts.min.js"' in out
def test_rewrite_does_not_touch_absolute_or_external() -> None:
for html in (
'<script src="/assets/echarts.min.js"></script>',
'<script src="https://cdn.example.com/echarts.min.js"></script>',
'<script src="//cdn.example.com/echarts.min.js"></script>',
'<script src="../assets/echarts.min.js"></script>',
):
assert site.rewrite_asset_paths(html, 2) == html, f"不该改写:{html}"
def test_rewrite_handles_href_too() -> None:
html = '<link href="assets/app.css" rel="stylesheet">'
assert '../../assets/app.css' in site.rewrite_asset_paths(html, 2)
# ---------------------------------------------------------------------------
# 前端文件
# ---------------------------------------------------------------------------
def test_frontend_source_files_exist() -> None:
src = project_root() / "web"
for name in ("index.html", "app.css", "app.js"):
assert (src / name).is_file(), f"缺少前端文件 web/{name}"
def test_frontend_index_references_local_assets_only() -> None:
html = (project_root() / "web" / "index.html").read_text(encoding="utf-8")
refs = re.findall(r'(?:src|href)="([^"]+)"', html)
external = [r for r in refs if r.startswith(("http://", "https://", "//"))]
assert not external, f"前端引用了外部资源(违反离线约束):{external}"
assert "app/app.js" in refs and "app/app.css" in refs
assert "assets/echarts.min.js" in refs
def test_frontend_uses_hash_routing_only() -> None:
"""hash 路由是刻意的:nginx 只需托管静态文件,不必配置 rewrite。"""
js = (project_root() / "web" / "app.js").read_text(encoding="utf-8")
assert "location.hash" in js
# 不应出现 history.pushState 这类需要服务端配合的写法
assert "pushState" not in js
def test_frontend_escapes_html() -> None:
"""用户可输入记录名称/备注,必须转义以避免 XSS。"""
js = (project_root() / "web" / "app.js").read_text(encoding="utf-8")
assert "function esc(" in js or "const esc =" in js
assert "&quot;" in js and "&lt;" in js
# ---------------------------------------------------------------------------
# API 行为(需要数据库)
# ---------------------------------------------------------------------------
def _db_ready() -> bool:
try:
from hdiv.core.config import load_config
from hdiv.data import db
db.load_dotenv_once()
db.list_tables(load_config("datasource"))
return True
except Exception:
return False
requires_db = pytest.mark.skipif(not _db_ready(), reason="数据库不可用")
@requires_db
def test_service_summary_shape() -> None:
from hdiv.web import service
s = service.summary()
for k in ("universes", "backtests", "profiles", "db"):
assert k in s, f"summary 缺少字段 {k}"
@requires_db
def test_soft_delete_is_reversible_and_never_physical() -> None:
"""软删除语义:记录仍在库中,可通过 include_deleted 找回。"""
from hdiv.web import service
runs = service.list_universes()
if not runs:
pytest.skip("没有筛选记录可供测试")
rid = runs[0]["run_id"]
try:
service.update_universe(rid, {"deleted": True})
visible = {r["run_id"] for r in service.list_universes()}
assert rid not in visible, "软删除后不应出现在默认列表"
allruns = {r["run_id"] for r in service.list_universes(include_deleted=True)}
assert rid in allruns, "软删除的记录必须仍可通过 include_deleted 找回(不做物理删除)"
finally:
service.update_universe(rid, {"deleted": False})
assert rid in {r["run_id"] for r in service.list_universes()}
@requires_db
def test_archive_toggles_visibility() -> None:
from hdiv.web import service
runs = service.list_universes()
if not runs:
pytest.skip("没有筛选记录可供测试")
rid = runs[0]["run_id"]
try:
service.update_universe(rid, {"archived": True})
assert rid not in {r["run_id"] for r in service.list_universes()}
assert rid in {r["run_id"] for r in service.list_universes(include_archived=True)}
finally:
service.update_universe(rid, {"archived": False})
@requires_db
def test_rename_persists_and_nullable() -> None:
from hdiv.web import service
runs = service.list_universes()
if not runs:
pytest.skip("没有筛选记录可供测试")
rid = runs[0]["run_id"]
original = runs[0]["display_name"]
try:
got = service.update_universe(rid, {"display_name": "单元测试名称"})
assert got["display_name"] == "单元测试名称"
assert got["title"] == "单元测试名称"
# 清空后应回落到自动标题,而不是留空
got = service.update_universe(rid, {"display_name": ""})
assert got["display_name"] is None
assert got["title"], "清空命名后应回落到自动标题"
finally:
service.update_universe(rid, {"display_name": original or ""})
@requires_db
def test_universe_backtest_link_is_bidirectional() -> None:
from hdiv.web import service
runs = service.list_universes()
bts = service.list_backtests()
if not runs or not bts:
pytest.skip("缺少筛选记录或回测记录")
rid, bid = runs[0]["run_id"], bts[0]["run_id"]
original = bts[0]["universe_run_id"]
try:
service.update_backtest(bid, {"universe_run_id": rid})
linked = service.list_backtests(universe_run_id=rid)
assert bid in {x["run_id"] for x in linked}, "回测侧应能按股票池过滤到"
u = service.get_universe(rid)
assert u["linked_backtests"] >= 1, "股票池侧应统计到关联回测数"
finally:
service.update_backtest(bid, {"universe_run_id": original})
@requires_db
def test_link_rejects_nonexistent_universe() -> None:
from hdiv.web import service
bts = service.list_backtests()
if not bts:
pytest.skip("没有回测记录")
with pytest.raises(ValueError):
service.update_backtest(bts[0]["run_id"], {"universe_run_id": "不存在的runid"})
@requires_db
def test_update_rejects_empty_patch() -> None:
from hdiv.web import service
runs = service.list_universes()
if not runs:
pytest.skip("没有筛选记录")
with pytest.raises(ValueError):
service.update_universe(runs[0]["run_id"], {})
@requires_db
def test_all_api_payloads_are_json_serializable() -> None:
"""回归:pandas 的 numpy 标量曾导致接口 500。"""
from hdiv.web import service
from hdiv.web.server import _Encoder
runs = service.list_universes()
payloads: list[object] = [service.summary(), runs]
if runs:
rid = runs[0]["run_id"]
payloads += [
service.get_universe(rid),
service.list_members(rid, size=2),
service.list_backtests(universe_run_id=rid),
]
bts = service.list_backtests()
if bts:
bid = bts[0]["run_id"]
payloads += [
service.get_backtest(bid),
service.get_backtest_metrics(bid),
service.get_backtest_equity(bid),
service.list_backtest_trades(bid, size=2),
service.list_backtest_signals(bid),
]
for p in payloads:
if p is None:
continue
json.dumps(p, ensure_ascii=False, cls=_Encoder) # 不应抛异常
@requires_db
def test_equity_index_overlay_is_date_aligned() -> None:
"""净值曲线右轴叠加的指数必须与日期**逐点对齐**。
类目轴上每个类目一个点:指数序列只要少一天,
整条指数线就会相对净值曲线整体错位,画出错误的对比。
"""
from hdiv.core.errors import HdivError
from hdiv.web import service
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有可用的回测")
items = service.list_indices()
if not items:
pytest.skip("hd_index_daily 没有指数行情")
assert [x["code"] for x in items if x["is_default"]] == [service.DEFAULT_INDEX_CODE], \
"应恰好把默认指数(沪深300)标成 default"
base = service.get_backtest_equity(rid)
if not base["dates"]:
pytest.skip("该回测没有净值曲线")
assert base["index"] is None, "不传 index 时不应凭空叠加指数"
for it in items:
ix = service.get_backtest_equity(rid, index_code=it["code"])["index"]
assert ix["code"] == it["code"] and ix["name"]
assert len(ix["close"]) == len(base["dates"]), f"{it['code']} 未与净值曲线对齐"
vals = [v for v in ix["close"] if v is not None]
# 叠加的是指数点位,不是净值;量级错了说明取错了列
assert not vals or min(vals) > 10, f"{it['code']} 取值不像指数点位:{vals[:3]}"
json.dumps(ix, allow_nan=False)
# 库里没有的指数应当明确报错,而不是画一条空线
with pytest.raises(HdivError):
service.get_backtest_equity(rid, index_code="999999.XX")
@requires_db
def test_reason_text_is_human_readable() -> None:
"""成交理由必须渲染成人话,而不是丢一坨 JSON 给前端。"""
from hdiv.web.service import _reason_text
txt = _reason_text({
"dividend_yield": 0.0575, "yield_percentile": 83.4,
"rule": "股息率历史分位 83.4% >= P75", "observation_count": 1200,
})
assert "股息率 5.75%" in txt
assert "历史分位 83.4%" in txt
assert "{" not in txt and "}" not in txt
# ---------------------------------------------------------------------------
# 部署资产(缺了它们就会出现「API 不可用」)
# ---------------------------------------------------------------------------
def test_nginx_example_has_api_proxy_before_static() -> None:
"""回归:nginx 示例必须包含 /api 反代,且排在静态规则之前。
漏掉反代块时,/ggx/api/health 会被当静态文件去 output/api/health 找 → 404,
页面能打开但显示「API 不可用」。
"""
cfg = (project_root() / "deploy" / "nginx.conf.example").read_text(encoding="utf-8")
proxy = cfg.find("proxy_pass")
assert proxy != -1, "nginx 示例缺少 proxy_pass(API 无法访问)"
# 反代块必须出现在静态 location 之前(nginx 前缀匹配取最长者,但顺位更易读且不易误删)
static = cfg.find("alias /srv/hddiv/site/")
assert static == -1 or proxy < static, "API 反代块应排在静态规则之前"
# location 与 proxy_pass 的末尾斜杠必须成对,否则路径会被改写错
assert re.search(r"location\s+/ggx/api/\s*\{", cfg)
assert "proxy_pass http://127.0.0.1:8099/api/;" in cfg
def test_launchd_template_is_valid_and_placeholder_based() -> None:
"""launchd 模板必须可渲染:占位符齐全、plist 结构完整。"""
import plistlib
tpl = (project_root() / "deploy" / "com.hddiv.web.plist.example")
assert tpl.is_file(), "缺少 launchd 模板"
raw = tpl.read_text(encoding="utf-8")
for ph in ("__PYTHON__", "__PROJECT_ROOT__"):
assert ph in raw, f"模板缺少占位符 {ph}"
# 注释里也有 __,解析时先剥掉注释
body = re.sub(r"<!--.*?-->", "", raw, flags=re.DOTALL)
data = plistlib.loads(body.encode("utf-8"))
assert data["Label"] == "com.hddiv.web"
assert data["RunAtLoad"] is True
assert data["KeepAlive"] == {"SuccessfulExit": False}, "应支持崩溃自愈"
assert "--api-only" in data["ProgramArguments"]
def test_install_service_script_placeholder_guard() -> None:
"""安装脚本必须检测未替换的占位符 —— 否则 launchd 会静默失败。"""
sh = (project_root() / "deploy" / "install-service.sh").read_text(encoding="utf-8")
assert "plutil -lint" in sh, "应校验 plist 格式"
assert "未替换的占位符" in sh or "grep -q \"__\"" in sh
assert "launchctl load" in sh and "launchctl unload" in sh
def test_serve_script_is_syntax_valid() -> None:
import subprocess
sh = project_root() / "deploy" / "serve.sh"
r = subprocess.run(["bash", "-n", str(sh)], capture_output=True, text=True)
assert r.returncode == 0, f"serve.sh 语法错误:{r.stderr}"
@requires_db
def test_members_default_returns_selected_only() -> None:
"""回归:不传 passed 时应返回「入选」股票,而不是全部候选。
曾默认返回全部候选(5000+ 只),与「股票清单」的语义不符。
"""
from hdiv.web import service
runs = service.list_universes()
if not runs:
pytest.skip("没有筛选记录")
rid = runs[0]["run_id"]
default = service.list_members(rid, passed=True, size=1)
allc = service.list_members(rid, passed=None, size=1)
rejected = service.list_members(rid, passed=False, size=1)
assert default["total"] <= allc["total"]
assert default["total"] + rejected["total"] == allc["total"], \
"入选数 + 淘汰数 应等于候选总数"
assert default["total"] > 0, "示例记录应有入选股票"
# ---------------------------------------------------------------------------
# HTML 报告降级为「导出件」
# ---------------------------------------------------------------------------
def test_html_flag_is_opt_in_in_cli() -> None:
"""回归:静态报告已降级为导出件 —— 默认不生成,需显式 --html。
改造后 SPA 是主界面,默认再产出 HTML 会:
1) 每次筛选多一个文件
2) 同 asof 重跑时按 asof 命名互相覆盖,与「每次运行都留痕」矛盾
"""
from hdiv.cli import build_parser
p = build_parser()
subs = {a.dest: a for a in p._actions if hasattr(a, "choices") and isinstance(a.choices, dict)}
sub = subs["command"].choices
for name in ("universe", "profile", "backtest", "audit", "sensitivity"):
args = p.parse_args([name] if name != "universe" else [name])
assert getattr(args, "html") is False, f"hdiv {name} 的 --html 应为 opt-in"
assert hasattr(args, "no_html"), f"hdiv {name} 应保留 --no-html 以免旧命令报错"
_ = sub # 断言子命令存在
def test_default_universe_run_writes_no_html(monkeypatch) -> None:
"""不传 --html 时不应调用 HTML 生成器。"""
from hdiv import cli
called = {"n": 0}
import hdiv.report.build as build
def fake(*a, **k):
called["n"] += 1
raise AssertionError("默认不应生成 HTML")
monkeypatch.setattr(build, "build_universe_report", fake)
# 仅验证解析结果:默认 html=False
p = cli.build_parser()
assert p.parse_args(["universe"]).html is False
assert p.parse_args(["universe", "--html"]).html is True
assert called["n"] == 0
def test_report_names_include_run_id() -> None:
"""报告文件名必须带执行 id,否则同 asof/同 symbol 重跑会互相覆盖。"""
from hdiv.core.config import load_config
n = load_config("report").naming
for key in ("universe", "profile", "backtest", "walkforward", "sensitivity"):
pattern = getattr(n, key)
assert "{run_id}" in pattern or "{wf_id}" in pattern or "{sens_id}" in pattern, \
f"naming.{key} 缺少执行 id 占位符,重跑会覆盖:{pattern}"
@requires_db
def test_universe_report_filenames_do_not_collide() -> None:
"""同 asof 的两条记录必须产出两个不同文件(实测回归)。"""
from hdiv.core.config import load_config
from hdiv.report.renderer import Renderer
r = Renderer()
a = r.name_from("universe", asof="2025-01-21", run_id="a" * 32)
b = r.name_from("universe", asof="2025-01-21", run_id="b" * 32)
assert a != b, "同 asof 不同 run 必须产生不同文件名"
assert load_config("report").naming.universe.startswith("reports/")
@requires_db
def test_universe_detail_exposes_chart_data() -> None:
"""前端图表所需数据由后端算好(前端不做业务计算)。"""
from hdiv.web import service
runs = service.list_universes()
if not runs:
pytest.skip("没有筛选记录")
u = service.get_universe(runs[0]["run_id"])
f = u["funnel"]
assert len(f["labels"]) == len(f["values"]) == 5
# 漏斗最后一段必须等于入选数,否则 stats 不自洽
assert f["values"][-1] == u["member_count"]
assert f["values"][0] == u["candidate_count"]
# 存活数必须单调不增
assert all(f["values"][i] >= f["values"][i + 1] for i in range(len(f["values"]) - 1)), \
f"漏斗存活数应单调不增:{f['values']}"
dist = u["industry_distribution"]
assert isinstance(dist, list)
if dist:
assert {"industry", "count"} <= set(dist[0])
assert dist[0]["count"] >= dist[-1]["count"], "行业分布应按数量降序"
# ---------------------------------------------------------------------------
# 站点根 index.html 的所有权(曾发生真实事故)
# ---------------------------------------------------------------------------
def test_naming_index_is_under_reports() -> None:
"""回归:静态报告索引曾与前端首页抢 output/index.html。
命名配置里 index 漏配 reports/ 前缀 + 调用点硬编码 "index.html",
导致跑一次 `hdiv audit --html`(内部会 build_index)就把前端首页
覆盖成静态报告索引。
"""
from hdiv.core.config import load_config
naming = load_config("report").naming
assert naming.index == "reports/index.html", \
f"naming.index 必须是 reports/index.html(当前 {naming.index})"
# 所有静态报告都应在 reports/ 下,不占用站点根
for key in ("index", "audit", "universe", "profile", "backtest",
"walkforward", "sensitivity"):
pattern = getattr(naming, key)
assert pattern.startswith("reports/"), \
f"naming.{key} 应输出到 reports/ 下,不占用站点根:{pattern}"
def test_renderer_refuses_to_write_site_root_index() -> None:
"""渲染器必须拒绝把报告写到站点根 index.html(前置拦截)。"""
from hdiv.core.errors import HdivError
from hdiv.report.renderer import Renderer
r = Renderer()
for bad in ("index.html", "./index.html"):
with pytest.raises(HdivError, match="统一前端首页冲突"):
r.render("reports/index.html", {}, bad, report_type="index")
def test_site_root_index_is_spa() -> None:
"""站点根 index.html 必须是前端外壳(引用 app/app.js),不是静态报告索引。"""
idx = project_root() / "output" / "index.html"
if not idx.is_file():
pytest.skip("尚未生成站点")
html = idx.read_text(encoding="utf-8")
assert "app/app.js" in html, "output/index.html 不是统一前端外壳(可能被报告覆盖)"
assert "报告索引" not in html or "app/app.js" in html
def test_build_index_uses_naming_config() -> None:
"""build_index 的输出路径必须来自 naming 配置,不能硬编码。"""
import inspect
from hdiv.report import build
src = inspect.getsource(build.build_index)
assert 'r.name_from("index")' in src, "build_index 应使用 naming 配置生成文件名"
# 不应再把 "index.html" 当输出名直接传进去
assert ' "index.html",\n' not in src, "build_index 仍在硬编码输出名"
def test_site_build_does_not_clobber_spa() -> None:
"""site.sync_frontend 之后,站点根 index.html 必须仍是 SPA。"""
from hdiv.web import site
site.sync_frontend(verbose=False)
html = (project_root() / "output" / "index.html").read_text(encoding="utf-8")
assert "app/app.js" in html
# ---------------------------------------------------------------------------
# 回测内分析:任意日持仓 + 个股买卖点
# ---------------------------------------------------------------------------
def _sample_backtest_run() -> str | None:
from hdiv.core.config import load_config
from hdiv.data import db
df = db.read_sql(
"SELECT r.run_id FROM hd_backtest_run r "
"JOIN hd_backtest_position p ON p.run_id = r.run_id "
"WHERE r.mode = 'single' "
"GROUP BY r.run_id ORDER BY COUNT(*) DESC LIMIT 1",
cfg=load_config("datasource"),
)
return None if df.empty else str(df["run_id"].iloc[0])
@requires_db
def test_position_dates_is_compact_by_default() -> None:
"""默认只返回日期字符串:带全字段会让响应从约 30KB 涨到 460KB。"""
from hdiv.web import analysis
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有带持仓的回测")
d = analysis.position_dates(rid)
assert "dates" in d and d["dates"], "应返回日期数组"
assert "items" not in d, "默认不应返回逐日全字段明细"
assert d["count"] == len(d["dates"])
assert d["dates"] == sorted(d["dates"]), "日期应升序"
# 紧凑形式必须显著更小
import json
compact = len(json.dumps(d, ensure_ascii=False).encode())
full = len(json.dumps(analysis.position_dates(rid, detail=True),
ensure_ascii=False).encode())
assert compact < full / 3, f"紧凑形式应远小于明细({compact} vs {full})"
@requires_db
def test_portfolio_falls_back_to_previous_trading_day() -> None:
"""非交易日应回退到之前最近的有快照交易日,并如实标注。"""
from hdiv.web import analysis
from hdiv.core.errors import HdivError
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有带持仓的回测")
dates = analysis.position_dates(rid)["dates"]
d = analysis.portfolio_on_date(rid, dates[-1])
assert d["date"] == dates[-1] and not d["adjusted"]
# 区间内但非交易日(用周末构造)
import datetime as _dt
mid = _dt.date.fromisoformat(dates[len(dates) // 2])
weekend = mid + _dt.timedelta(days=(5 - mid.weekday()) % 7 + 1)
d2 = analysis.portfolio_on_date(rid, weekend.isoformat())
assert d2["date"] <= weekend.isoformat()
assert d2["adjusted"] is True, "非交易日应标注已回退"
# 早于首个快照应给出可理解错误
with pytest.raises(HdivError, match="早于该回测的首个快照"):
analysis.portfolio_on_date(rid, "1990-01-01")
@requires_db
def test_portfolio_summary_is_internally_consistent() -> None:
"""持仓汇总必须自洽:市值合计 = 逐股之和;权重合计 ≈ 仓位占比。"""
from hdiv.web import analysis
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有带持仓的回测")
dates = analysis.position_dates(rid)["dates"]
# 找一个有持仓的交易日
for day in reversed(dates):
d = analysis.portfolio_on_date(rid, day)
if d["positions"]:
break
else:
pytest.skip("没有非空持仓日")
s = sum(p["market_value"] or 0 for p in d["positions"])
assert abs(s - d["summary"]["market_value"]) < 1.0
assert d["summary"]["count"] == len(d["positions"])
w = sum(p["weight"] or 0 for p in d["positions"])
tv = d["equity"]["total_value"] or 0
if tv:
assert abs(w - d["summary"]["market_value"] / tv) < 0.02, \
f"权重合计 {w:.4f} 应约等于仓位占比 {d['summary']['market_value']/tv:.4f}"
# 每只股票都应带名称(JOIN stock)
assert all(p["symbol"] for p in d["positions"])
@requires_db
def test_stock_detail_series_and_trades() -> None:
"""个股买卖点:序列长度一致、买卖点带完整成交信息。"""
from hdiv.web import analysis
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有带持仓的回测")
stocks = analysis.run_stocks(rid)
if not stocks:
pytest.skip("该回测没有持仓股票")
sym = stocks[0]["symbol"]
d = analysis.stock_detail(rid, sym)
n = len(d["dates"])
assert n > 0
for k, v in d["series"].items():
assert len(v) == n, f"序列 {k} 长度与日期不一致({len(v)} vs {n})"
assert "close" in d["series"]
# 股息率必须在合理量级内(单位错误会让它变成 0 或几百)
dv = [x for x in d["series"]["dv_yield"] if x is not None]
if dv:
assert max(dv) < 1.0, f"股息率不应超过 100%:{max(dv)}"
assert min(dv) >= 0.0, "股息率不应为负"
for t in d["trades"]:
assert t["side"] in {"BUY", "SELL"}
assert t["price"] and t["price"] > 0
assert t["quantity"] and t["quantity"] > 0
assert t["amount"] and t["amount"] > 0
assert t["reason_text"] and t["reason_text"] != ""
assert d["stats"]["trade_count"] == len(d["trades"])
@requires_db
def test_stock_detail_with_sell_trades_does_not_500() -> None:
"""回归:有卖出的个股必须能打开。
卖出成交的 ``holding_days`` 在库里是 NaN 而**不是** None,
老代码 ``int(r["holding_days"]) if ... is not None else None`` 会抛
``ValueError: cannot convert float NaN to integer``,
让个股详情接口 500 —— 22/35 只有卖出的个股整页打不开。
"""
from hdiv.web import analysis
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有带持仓的回测")
stocks = analysis.run_stocks(rid)
sells = [s for s in stocks if (s.get("sell_count") or 0) > 0]
if not sells:
pytest.skip("该回测没有卖出成交")
sym = sells[0]["symbol"]
d = analysis.stock_detail(rid, sym) # 老代码在这一行 500
assert any(t["side"] == "SELL" for t in d["trades"]), "应至少有一笔卖出"
for t in d["trades"]:
hd = t["holding_days"]
assert hd is None or isinstance(hd, int), f"holding_days 应为整数或 None:{hd!r}"
assert hd is None or hd >= 0
# NaN 会以非法 JSON 的形式漏到前端,这里一并卡住
json.dumps(d, allow_nan=False)
@requires_db
def test_stock_detail_respects_series_selection() -> None:
"""勾选哪些指标就只算哪些(不为没勾的做无谓计算)。"""
from hdiv.web import analysis
from hdiv.core.errors import HdivError
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有带持仓的回测")
sym = analysis.run_stocks(rid)[0]["symbol"]
d = analysis.stock_detail(rid, sym, series=["close", "roe"])
assert set(d["series"]) == {"close", "roe"}
assert "pe_ttm" not in d["series"]
# 无效指标应报错而不是静默忽略
with pytest.raises(HdivError):
analysis.stock_detail(rid, sym, series=["不存在的指标"])
@requires_db
def test_roe_series_is_stepwise_not_interpolated() -> None:
"""ROE 必须按公告日对齐成阶梯(插值会造出当时不存在的值)。"""
from hdiv.web import analysis
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有带持仓的回测")
sym = analysis.run_stocks(rid)[0]["symbol"]
d = analysis.stock_detail(rid, sym, series=["roe"])
vals = [x for x in d["series"]["roe"] if x is not None]
if len(vals) < 50:
pytest.skip("ROE 样本不足")
# 阶梯序列的不同取值数应远少于样本数(季度更新,约 4 次/年)
distinct = len(set(round(v, 6) for v in vals))
assert distinct < len(vals) / 5, \
f"ROE 取值数 {distinct} 相对样本 {len(vals)} 过多,疑似插值而非阶梯"
# ---------------------------------------------------------------------------
# Walk-forward 前端可见性
# ---------------------------------------------------------------------------
@requires_db
def test_walkforward_list_and_detail() -> None:
"""Walk-forward 记录必须在接口层可见(此前完全没有入口)。"""
from hdiv.web import service
items = service.list_walkforwards()
if not items:
pytest.skip("没有 walk-forward 记录")
w = items[0]
assert w["wf_id"] and w["window_count"] > 0
assert w["title"], "应有可读标题"
assert w["strategy"]["conditions"], "应带策略条件说明"
assert "oos" in w and w["oos"].get("window_count") == w["window_count"]
d = service.get_walkforward(w["wf_id"])
assert d is not None
assert len(d["windows"]) == w["window_count"]
for win in d["windows"]:
# 每个窗口都必须有训练段与测试段
assert win["train_start"] and win["test_start"]
assert win["train_run_id"] and win["test_run_id"]
assert "in_sample" in win and "out_of_sample" in win
s = d["summary"]
assert len(s["oos_returns"]) == w["window_count"]
assert s["oos_mean"] is not None
assert 0.0 <= s["oos_win_rate"] <= 1.0
@requires_db
def test_walkforward_summary_is_consistent() -> None:
"""汇总必须与逐窗口数据自洽(曾靠 metric 行数反推导致胜率算错)。"""
from hdiv.web import service
items = service.list_walkforwards()
if not items:
pytest.skip("没有 walk-forward 记录")
for w in items[:3]:
d = service.get_walkforward(w["wf_id"])
rets = [x["out_of_sample"].get("total_return") for x in d["windows"]]
rets = [x for x in rets if x is not None]
if not rets:
continue
assert abs(d["summary"]["oos_mean"] - sum(rets) / len(rets)) < 1e-9
expect_win = sum(1 for x in rets if x > 0) / len(rets)
assert abs(d["summary"]["oos_win_rate"] - expect_win) < 1e-9
# 列表页的汇总应与详情页一致
assert abs((w["oos"]["mean_return"] or 0) - d["summary"]["oos_mean"]) < 1e-9
def test_walkforward_frontend_page_exists() -> None:
"""前端必须有 walk-forward 页与导航入口。"""
js = (project_root() / "web" / "app.js").read_text(encoding="utf-8")
html = (project_root() / "web" / "index.html").read_text(encoding="utf-8")
assert "viewWalkforwards" in js and "viewWalkforwardDetail" in js
assert "#/walkforwards" in html, "导航缺「样本外」入口"
assert "mountWalkforwardDetail" in js, "详情页应挂载对比图"
@requires_db
def test_walkforward_exposes_benchmark_and_excess() -> None:
"""回归:基准指标存为 benchmark_<code>,曾被 benchmark_code='' 过滤掉,
导致页面上看不到最重要的「超额收益」。"""
from hdiv.web import service
items = service.list_walkforwards()
if not items:
pytest.skip("没有 walk-forward 记录")
d = service.get_walkforward(items[0]["wf_id"])
s = d["summary"]
assert s.get("benchmark_mean") is not None, "缺少基准均值"
assert s.get("excess_mean") is not None, "缺少超额收益均值"
assert s.get("excess_win_rate") is not None
got = 0
for w in d["windows"]:
if w["benchmark_return"] is None:
continue
got += 1
assert w["benchmark_code"], "应记录基准代码"
o = w["out_of_sample"].get("total_return")
if o is not None:
assert abs(w["excess_return"] - (o - w["benchmark_return"])) < 1e-9, \
"超额必须等于 策略收益 − 基准收益"
# 基准行不得混进策略指标里
assert not any(k.startswith("benchmark::") for k in w["out_of_sample"])
assert got > 0, "没有任何窗口带基准收益"
@requires_db
def test_walkforward_frozen_params_record_calibration() -> None:
"""冻结参数必须记录「校准出的绝对阈值」,而不只是配置里的分位。
训练段的作用是把相对分位(P75)转成绝对股息率;若只有分位、
没有绝对阈值,说明训练段实际上没做校准。
"""
from hdiv.web import service
items = service.list_walkforwards()
if not items:
pytest.skip("没有 walk-forward 记录")
d = service.get_walkforward(items[0]["wf_id"])
abs_entries = []
for w in d["windows"]:
f = w["frozen_params"]
assert "entry_yield_percentile" in f, "应保留分位口径"
assert f.get("absolute_entry_yield"), f"窗口 {w['window_index']} 缺校准阈值"
assert f.get("calibration_obs"), "应记录校准样本数"
abs_entries.append(f["absolute_entry_yield"])
# 各窗口的绝对阈值应随市场水平变化(全相同说明没真校准)
assert len(set(round(x, 6) for x in abs_entries)) > 1, \
"各窗口校准出的绝对阈值完全相同,疑似未真正校准"