功能: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 项通过。
This commit is contained in:
+386
-12
@@ -26,10 +26,12 @@ from hdiv.web.server import ROUTES
|
||||
#: 路径样例用于匹配路由正则;改前端时需同步此表。
|
||||
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"),
|
||||
@@ -38,8 +40,18 @@ FRONTEND_CALLS: list[tuple[str, str]] = [
|
||||
("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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -87,18 +99,25 @@ def test_members_endpoint_defaults_to_selected() -> None:
|
||||
"未指定 passed 时应视为 1(仅入选)"
|
||||
|
||||
|
||||
def test_every_route_has_a_frontend_or_cli_consumer() -> None:
|
||||
"""反向检查:后端不应暴露无人使用的接口(便于发现遗留死接口)。"""
|
||||
known_paths = {p for _m, p in FRONTEND_CALLS}
|
||||
orphans = []
|
||||
for _m, pat, fn in ROUTES:
|
||||
# 用契约表中的样例路径试探该路由是否有消费者
|
||||
sample = pat.pattern.replace("^", "").replace("$", "")
|
||||
sample = re.sub(r"\(\?P<\w+>\[[^\]]+\]\+?\)", "abc123", sample)
|
||||
if not any(pat.match(p) for p in known_paths) and "/stocks/" not in sample:
|
||||
orphans.append((_m, pat.pattern))
|
||||
# /stocks/ 由画像页使用;/universes/{id}/members/{sym} 为可选下钻
|
||||
assert len(orphans) <= 2, f"疑似无人使用的接口:{orphans}"
|
||||
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"
|
||||
"新增接口时请同步更新契约表,否则前端改动无法被发现。"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -326,6 +345,44 @@ def test_all_api_payloads_are_json_serializable() -> None:
|
||||
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 给前端。"""
|
||||
@@ -573,3 +630,320 @@ def test_site_build_does_not_clobber_spa() -> None:
|
||||
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, \
|
||||
"各窗口校准出的绝对阈值完全相同,疑似未真正校准"
|
||||
|
||||
Reference in New Issue
Block a user