"""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 = '' assert site.rewrite_asset_paths(html, 0) == html def test_rewrite_asset_paths_depth2() -> None: html = '' 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 ( '', '', '', '', ): assert site.rewrite_asset_paths(html, 2) == html, f"不该改写:{html}" def test_rewrite_handles_href_too() -> None: html = '' 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 """ in js and "<" 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_,曾被 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, \ "各窗口校准出的绝对阈值完全相同,疑似未真正校准"