说明:本提交是工作区中此前的未提交工作(在 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 模式的主要性能瓶颈,建议下一轮优化。
1249 lines
52 KiB
Python
1249 lines
52 KiB
Python
"""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"),
|
||
# 每日动态股票池(--mode daily):时间线 / 某日成员
|
||
("GET", "/api/backtests/abc123/daily-universe"),
|
||
("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"),
|
||
# 已清仓了结清单(含清仓后至今涨跌)
|
||
("GET", "/api/backtests/abc123/closed-positions"),
|
||
# 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_router_sentinel_is_not_the_home_path() -> None:
|
||
"""路由的「已渲染路径」哨兵不能是空串 —— 首页路径本身就是 ''。
|
||
|
||
历史 bug:``let currentPath = ''`` 且所有强制重渲染都写 ``currentPath = ''``。
|
||
首页(hash 为空)解析出的 path 恰好也是 '',于是 render() 一进门就命中
|
||
``path === currentPath`` 提前返回:index.html 里那句「加载中…」永远不被替换,
|
||
概览页整页打不开(其他页面因为有非空路径,反而正常)。哨兵改用 null 后,
|
||
'' 才能被当作一个正常的、需要渲染的路径。
|
||
"""
|
||
js = (project_root() / "web" / "app.js").read_text(encoding="utf-8")
|
||
# 只看赋值(排除 === 比较)
|
||
assigns = [a.strip() for a in re.findall(r"currentPath\s*=(?!=)\s*([^;\n]+)", js)]
|
||
assert assigns, "未在 app.js 中找到 currentPath 赋值,解析逻辑需更新"
|
||
bad = [a for a in assigns if a in {"''", '""'}]
|
||
assert not bad, f"currentPath 不能用空串作哨兵(与首页路径 '' 冲突):{bad}"
|
||
assert "let currentPath = null" in js
|
||
|
||
|
||
def test_profile_gate_is_exposed_for_display() -> None:
|
||
"""画像闸门是买入判据的一部分,必须能在回测页看到。
|
||
|
||
看不到就会出现「股息率分位到了却没买」无从解释的情况 ——
|
||
闸门规则是**第二道**买入条件,和 run 一起要能复现。
|
||
"""
|
||
from hdiv.web.service import describe_strategy
|
||
|
||
cfg = {
|
||
"strategy": {"id": "S", "name": "n", "version": "1", "status": "DRAFT",
|
||
"description": ""},
|
||
"entry": {
|
||
"yield_percentile": 75,
|
||
"profile_gate": {
|
||
"enabled": True, "window_years": 5, "on_unverifiable": "reject",
|
||
"min_window_coverage": 0.0,
|
||
"rules": [
|
||
{"metric": "payout_ratio", "stat": "current_value",
|
||
"op": "<=", "value": 1.0},
|
||
# 没写 stat:应默认当日值,且不能把规则丢掉
|
||
{"metric": "roe_avg", "op": ">=", "value": 0.08},
|
||
],
|
||
},
|
||
},
|
||
}
|
||
g = describe_strategy(cfg)["profile_gate"]
|
||
assert g["enabled"] is True and g["window_years"] == 5.0
|
||
assert g["on_unverifiable"] == "reject"
|
||
assert [r["metric"] for r in g["rules"]] == ["payout_ratio", "roe_avg"]
|
||
assert g["rules"][0]["op"] == "<=" and g["rules"][0]["value"] == 1.0
|
||
assert g["rules"][1]["stat"] == "current_value"
|
||
json.dumps(g, ensure_ascii=False, allow_nan=False)
|
||
|
||
# 老配置没有这一段 → None,前端据此不显示卡片
|
||
assert describe_strategy({"strategy": {}, "entry": {}})["profile_gate"] is None
|
||
# 脏数据不能把整页带崩,也不能造出假规则
|
||
dirty = describe_strategy({"strategy": {},
|
||
"entry": {"profile_gate": {"enabled": True,
|
||
"rules": [None, {}, "x"]}}})
|
||
assert dirty["profile_gate"]["rules"] == []
|
||
|
||
|
||
def test_profile_gate_card_is_wired_into_backtest_page() -> None:
|
||
"""回测页必须真的把画像闸门渲染出来(接口有字段≠页面显示)。"""
|
||
js = (project_root() / "web" / "app.js").read_text(encoding="utf-8")
|
||
assert "profileGateCard" in js, "缺少画像闸门卡片渲染函数"
|
||
assert "个股画像筛选条件" in js, "缺少画像闸门卡片标题"
|
||
assert "profile_gate" in js, "未把接口字段接到卡片上"
|
||
# 卡片要挂在「回测条件」之后
|
||
cond = js.index(">回测条件<")
|
||
gate = js.index("profileGateCard(b.strategy.profile_gate)")
|
||
assert cond < gate, "画像闸门卡片必须在「回测条件」之后"
|
||
|
||
|
||
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_stock_detail_default_range_reaches_latest_data() -> None:
|
||
"""回归:默认区间要到**该股最新行情**,而不是持仓结束(卖出)当天。
|
||
|
||
老实现默认用「持仓区间」,卖出之后曲线就断了,
|
||
「卖飞了没有」这个最该回答的问题在图上无从回答。
|
||
这里特意挑「已清仓、且清仓日之后还有行情」的样本 —— 正是老实现会断线的场景。
|
||
"""
|
||
from hdiv.core.config import load_config
|
||
from hdiv.data import db
|
||
from hdiv.web import analysis
|
||
|
||
cfg = load_config("datasource")
|
||
df = db.read_sql(
|
||
"SELECT p.run_id, p.symbol, p.hold_start, p.hold_end, d.avail_end "
|
||
"FROM (SELECT run_id, symbol, MIN(trade_date) AS hold_start, "
|
||
" MAX(trade_date) AS hold_end "
|
||
" FROM hd_backtest_position GROUP BY run_id, symbol) p "
|
||
"JOIN (SELECT symbol, MAX(trade_date) AS avail_end "
|
||
" FROM daily_basic GROUP BY symbol) d ON d.symbol = p.symbol "
|
||
"WHERE p.hold_end < d.avail_end "
|
||
"ORDER BY p.hold_end LIMIT 1",
|
||
cfg=cfg,
|
||
)
|
||
if df.empty:
|
||
pytest.skip("库里没有「已清仓且之后仍有行情」的样本")
|
||
row = df.iloc[0]
|
||
rid, sym = str(row["run_id"]), str(row["symbol"])
|
||
hold_start, hold_end, avail_end = (str(row["hold_start"]), str(row["hold_end"]),
|
||
str(row["avail_end"]))
|
||
|
||
r = analysis.stock_detail(rid, sym, series=["close"])["range"]
|
||
assert r["available_start"] and r["available_end"], "应返回该股行情边界供日期选择器用"
|
||
assert r["end"] == avail_end, \
|
||
f"默认区间止于 {r['end']},而行情已到 {avail_end}(又回到「卖出即断线」)"
|
||
assert r["start"] <= hold_start, "默认起点不应晚于持仓起点(判据数据要在图上)"
|
||
assert r["end"] > hold_end, f"默认区间不应停在清仓日 {hold_end}"
|
||
|
||
|
||
@requires_db
|
||
def test_stock_detail_default_range_includes_judgement_lookback() -> None:
|
||
"""默认区间要含**首笔成交之前**的判据数据。
|
||
|
||
买入依据是股息率的历史分位(滚动窗口,config: percentile_reference
|
||
.lookback_years = 5 年);只画持仓期等于把「当时凭什么买」的判据裁掉了。
|
||
"""
|
||
import datetime as _dt
|
||
|
||
from hdiv.web import analysis
|
||
|
||
rid = _sample_backtest_run()
|
||
if not rid:
|
||
pytest.skip("没有可用的回测")
|
||
stocks = [s for s in analysis.run_stocks(rid) if (s.get("trade_count") or 0) > 0]
|
||
if not stocks:
|
||
pytest.skip("该回测没有成交")
|
||
sym = stocks[0]["symbol"]
|
||
|
||
d = analysis.stock_detail(rid, sym, series=["close"])
|
||
r = d["range"]
|
||
first = min(t["execution_date"] for t in d["trades"])
|
||
need = _dt.date.fromisoformat(first) - _dt.timedelta(days=int(365.25 * 5))
|
||
assert r["start"] <= need.isoformat(), \
|
||
f"默认起点 {r['start']} 未覆盖首笔成交({first})之前 5 年的判据数据"
|
||
# 但不该早于该股行情本身(否则日期选择器会给出选不到的日期)
|
||
assert r["start"] >= r["available_start"]
|
||
|
||
|
||
@requires_db
|
||
def test_downsampling_keeps_trade_dates() -> None:
|
||
"""回归:降采样不能把成交日丢掉。
|
||
|
||
前端按**日期**把买卖点落到横轴上,横轴里没有那一天,
|
||
这笔成交就会从图上消失(还会被前端误报成「不在所选区间内」)。
|
||
实测:默认区间放宽到「5 年判据 + 至今」后,13 只降采样股票里有 7 只会丢成交日。
|
||
"""
|
||
from hdiv.web import analysis
|
||
|
||
rid = _sample_backtest_run()
|
||
if not rid:
|
||
pytest.skip("没有可用的回测")
|
||
checked = 0
|
||
for s in analysis.run_stocks(rid):
|
||
d = analysis.stock_detail(rid, s["symbol"], series=["close"])
|
||
if not d["range"]["downsampled"]:
|
||
continue
|
||
checked += 1
|
||
axis = set(d["dates"])
|
||
missing = [t["execution_date"] for t in d["trades"]
|
||
if t["execution_date"] not in axis]
|
||
assert not missing, \
|
||
f"{s['symbol']} 降采样后丢了成交日 {missing},图上会少标这几笔"
|
||
assert d["dates"] == sorted(d["dates"]), "横轴仍须按时间升序"
|
||
if not checked:
|
||
pytest.skip("该回测没有触发降采样的个股")
|
||
|
||
|
||
@requires_db
|
||
def test_stock_detail_date_params_and_validation() -> None:
|
||
"""区间参数:能收窄、非法输入报可读错误、区间外成交仍要返回。"""
|
||
import datetime as _dt
|
||
|
||
from hdiv.core.errors import HdivError
|
||
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"]
|
||
|
||
base = analysis.stock_detail(rid, sym, series=["close"])
|
||
a = _dt.date.fromisoformat(base["range"]["start"])
|
||
s, e = a.isoformat(), (a + _dt.timedelta(days=180)).isoformat()
|
||
|
||
d = analysis.stock_detail(rid, sym, start=s, end=e, series=["close"])
|
||
assert d["range"]["requested_start"] == s and d["range"]["requested_end"] == e
|
||
assert s <= d["range"]["start"] and d["range"]["end"] <= e
|
||
assert d["range"]["points"] < base["range"]["points"], "收窄区间应真的少取数据"
|
||
assert d["range"]["default_end"] == base["range"]["default_end"], \
|
||
"default_* 应是「重置」用的缺省区间,不随本次请求变化"
|
||
# 区间外的成交仍要返回:前端靠它提示「有 N 笔不在所选区间内」
|
||
assert len(d["trades"]) == len(base["trades"])
|
||
|
||
with pytest.raises(HdivError):
|
||
analysis.stock_detail(rid, sym, start=e, end=s, series=["close"])
|
||
for bad in ("2024-13-45", "not-a-date"):
|
||
with pytest.raises(HdivError):
|
||
analysis.stock_detail(rid, sym, start=bad, series=["close"])
|
||
|
||
|
||
@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 test_published_site_is_world_readable() -> None:
|
||
"""发布产物必须 world-readable:nginx worker 以 nobody 运行,不是文件属主。
|
||
|
||
``shutil.copy2`` 会保留源文件权限,所以一个 umask 077 存下来的 600 文件
|
||
会让线上 CSS/JS 直接 403(HTML 打得开、页面裸奔)。发布时统一收敛权限。
|
||
"""
|
||
from hdiv.web import site
|
||
|
||
site.sync_frontend(verbose=False)
|
||
out = project_root() / "output"
|
||
unreadable = [p for p in out.rglob("*") if p.is_file() and not p.stat().st_mode & 0o044]
|
||
assert not unreadable, f"这些发布文件 nginx(nobody)读不到:{unreadable[:5]}"
|
||
untraversable = [p for p in out.rglob("*") if p.is_dir() and not p.stat().st_mode & 0o011]
|
||
assert not untraversable, f"这些目录 nginx(nobody)进不去:{untraversable[:5]}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 回测内分析:任意日持仓 + 个股买卖点
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
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_closed_positions_definition_and_math() -> None:
|
||
"""已清仓清单:定义(期末不再持有)+ 口径(收益率、清仓后涨跌)都要对得上。
|
||
|
||
「已清仓」若按成交净额判断会漏掉「卖了又买回、期末仍持有」的票,
|
||
这里同时用两套口径交叉验证,并要求金额/盈亏与成交表逐笔汇总一致。
|
||
"""
|
||
from hdiv.core.config import load_config
|
||
from hdiv.data import db
|
||
from hdiv.core.errors import HdivError
|
||
from hdiv.web import analysis
|
||
|
||
cfg = load_config("datasource")
|
||
# 找一只有已清仓个股的回测(期末持仓数 < 曾持有数)
|
||
df = db.read_sql(
|
||
"SELECT run_id FROM hd_backtest_position GROUP BY run_id "
|
||
"HAVING COUNT(DISTINCT symbol) > "
|
||
" (SELECT COUNT(DISTINCT symbol) FROM hd_backtest_position p2 "
|
||
" WHERE p2.run_id = hd_backtest_position.run_id "
|
||
" AND p2.trade_date = (SELECT MAX(trade_date) FROM hd_backtest_position p3 "
|
||
" WHERE p3.run_id = hd_backtest_position.run_id)) "
|
||
"ORDER BY COUNT(DISTINCT symbol) DESC LIMIT 1",
|
||
cfg=cfg,
|
||
)
|
||
if df.empty:
|
||
pytest.skip("没有含已清仓个股的回测")
|
||
rid = str(df["run_id"].iloc[0])
|
||
|
||
d = analysis.closed_positions(rid)
|
||
items = d["items"]
|
||
assert items, "该回测应当有已清仓个股"
|
||
json.dumps(d, ensure_ascii=False, allow_nan=False) # NaN 不能漏到前端
|
||
|
||
last_day = str(db.read_sql(
|
||
"SELECT MAX(trade_date) AS d FROM hd_backtest_position WHERE run_id = :r",
|
||
{"r": rid}, cfg=cfg)["d"].iloc[0])
|
||
still = set(db.read_sql(
|
||
"SELECT DISTINCT symbol FROM hd_backtest_position "
|
||
"WHERE run_id = :r AND trade_date = :d", {"r": rid, "d": last_day}, cfg=cfg)["symbol"])
|
||
|
||
tr = db.read_sql(
|
||
"SELECT symbol, side, quantity, amount, realized_pnl, execution_date "
|
||
"FROM hd_backtest_trade WHERE run_id = :r", {"r": rid}, cfg=cfg)
|
||
for x in items:
|
||
assert x["symbol"] not in still, f"{x['symbol']} 期末仍持有,不该出现在已清仓清单"
|
||
mine = tr[tr["symbol"] == x["symbol"]]
|
||
buys = mine[mine["side"] == "BUY"]
|
||
sells = mine[mine["side"] == "SELL"]
|
||
assert len(sells) > 0, "已清仓必然有卖出成交"
|
||
# 刻意**不**校验「买入股数 == 卖出股数」:送股/转增会让持仓股数凭空增加
|
||
# (实测 600188.SH 在 93fb7456 里买入 6000 股、卖出 11700 股)。
|
||
# 所以「已清仓」只能以持仓表为准,不能用成交净额反推。
|
||
assert x["last_sell"] == str(sells["execution_date"].max())
|
||
assert abs((x["realized_pnl"] or 0) - float(sells["realized_pnl"].sum())) < 1e-6
|
||
assert abs((x["buy_amount"] or 0) - float(buys["amount"].sum())) < 1e-6
|
||
assert x["first_hold"] and x["last_hold"] and x["hold_days"] > 0
|
||
if x["return_pct"] is not None: # 已清仓 ⇒ 收益率 = 已实现盈亏 / 买入金额
|
||
assert abs(x["return_pct"] - x["realized_pnl"] / x["buy_amount"]) < 1e-9
|
||
if x["since_sell_pct"] is not None: # 清仓后涨跌以清仓日收盘为基准
|
||
assert abs(x["since_sell_pct"] -
|
||
(x["close_latest"] / x["close_at_sell"] - 1.0)) < 1e-9
|
||
|
||
s = d["summary"]
|
||
assert s["count"] == len(items)
|
||
assert abs(s["realized_pnl"] - sum(x["realized_pnl"] or 0 for x in items)) < 1e-6
|
||
assert s["since_sell_up"] + s["since_sell_down"] <= s["count"]
|
||
# 明细按清仓日倒序(最近清仓的排在最前)
|
||
dates = [x["last_sell"] or "" for x in items]
|
||
assert dates == sorted(dates, reverse=True)
|
||
|
||
with pytest.raises(HdivError):
|
||
analysis.closed_positions("不存在的runid")
|
||
|
||
|
||
@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, \
|
||
"各窗口校准出的绝对阈值完全相同,疑似未真正校准"
|