Files
qlib/scripts/verify_backtest_page_contract.py
Simon 48a97c2a12 feat(backtest): 买卖点理由(用数据说话)+ 因子曲线 + 曲线新页面放大
用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。

一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `quant/trade_reasons.py`:封闭词表 + 文案构造器,组合引擎与单策略引擎共用,
  避免两个引擎对同一件事写出两种说法。理由里带**引擎当时的真实数字**:
  综合分名次/候选数/综合分/各因子原始值/持有交易日/预算与最低佣金/涨停比值等。
- 买入:按名次建仓、顺延成交、涨停未买、停牌未买、现金不足、不足最低佣金;
  卖出:跌出 TopN(含第几名掉出)、被股票池过滤(与「跌出 TopN」分开写)、
  超 Tmax 强制了结、Tmin 保护暂留、停牌/跌停顺延。
- `ActionRecord.reason` 覆盖**成交与未成交**全部买卖点(原 `reject_reason` 保留不动,
  老归档仍可读);`Trade.entry_reason / exit_reason` 跟着成交记录走。
- 名次来自调仓日完整排名(新增 `_ranked_by_day`),拿不到名次时如实写「未给出名次」,
  绝不编造一个名次填进去。
- 未成交明细不再只写执行层原因:把「为什么选中它、当时各因子多少」一并给出。

二、因子曲线
- `FactorCurve`:每个策略因子一条曲线,值为**当日持仓按市值加权平均的原始值**
  (不做 z-score、不按方向取反,空仓日不落点、不插值、不用 0 填充),并带
  label/direction/unit 供界面说明口径;`FactorDef/FactorTemplate` 新增 `unit`
  (股息率 %、量比/接近新高 倍数、动量等 小数),11 个内置因子实例已逐一核对。
- 归档体积预算照旧按整包计量,无需改迁移。

三、界面
- 结果页新增「买卖说明」区块:全部买卖点 + 理由 + 数字标签,支持方向/成交状态/关键字
  筛选与日期排序;成交明细表加「为什么买 / 为什么卖」两列;新增「因子曲线」区块,
  每条曲线标出组合成交日,直接对照「买卖发生在什么水平」。
- 「新页面放大」:每条曲线(净值/回撤/因子/个股/月度)都能开 `/charts/{归档id}?s=...`
  整页看大图;放大页是 Server Component,数据从归档直出,URL 可分享且与归档一致。
  未归档的结果如实说明「未归档,无法放大」,不给坏链接。
- 数字格式与后端 `f"{v:.4f}"` 同规则(四舍六入五成双):修掉 0.03125 在理由原文里
  显示 0.0312、旁边标签显示 0.0313 的不一致(17 组边界值与 Python 逐一比对一致)。
- `/factors/compose` 结果区改用同一个 `BacktestResultView`,两处口径不会再漂移。

验证:
- 新增 `tests/test_trade_reasons.py` 8 条(买入数字、跌出 TopN 名次、不在候选池、
  Tmax、Tmin 暂留、涨停未成交、因子曲线加权值、空仓不落点);后端 510 条全过,ruff clean。
- 真实数据端到端:`/api/combos/run` 6 个月高股息组合(EXP-8EA2819B)13 个买卖点
  100% 带理由与数字,因子曲线 dividend_yield 117 点、单位 %;
  `scripts/verify_backtest_page_contract.py`(4 年、301 个买卖点、140 笔成交)扩展断言
  理由词表/名次/因子值/曲线单调性后通过。
- 浏览器实测:归档详情页与放大页 `/charts/...?s=factor:dividend_yield` 等 5 种曲线
  全部 200 渲染,截图确认表格与曲线数值正确。
2026-10-01 17:57:00 +08:00

206 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""前端页面契约验证:按回测页实际下发的请求体与读取路径校验后端字段,防结构漂移。
基准是页面参数模型的「高股息案例预设」(`frontend/web/components/StrategyParamsForm.tsx`
的 `CASE_PRESET`)+ 页面 `run()` 组装出的 spec 形状;结果侧的断言对应
`app/backtest/page.tsx` 的 ResultView(净值/回撤/个股曲线、买卖点、config_snapshot 口径)。
与 `verify_strategy_workspace.py` 的分工:
- 本脚本 = 回测结果**结构契约**(跑一次完整 2020→ 区间,约 5 分钟);
- 另一个 = 策略库/说明/名称/选股直通**接口契约**(含一次 1 年回测,约 4 分钟)。
用法:PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py [--end 2024-12-31]
"""
from __future__ import annotations
import argparse
import json
import time
import urllib.request
API = "http://127.0.0.1:8000"
def _post(path: str, payload: dict) -> dict:
req = urllib.request.Request(
API + path,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=120) as r: # noqa: S310
return json.loads(r.read().decode())
def _get(path: str) -> dict:
with urllib.request.urlopen(API + path, timeout=120) as r: # noqa: S310
return json.loads(r.read().decode())
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--end", default="2024-12-31")
args = p.parse_args()
# —— 与 CASE_PRESET(高股息案例预设)逐字段一致;min_listing_days 用 250 ——
spec = {
"type": "backtest",
"universe": {"exclude_st": True, "min_listing_days": 250},
"price_adjustment": "hfq",
"factors": [{"name": "dividend_yield", "weight": 1}],
"conditions": [{"field": "dv_ratio", "op": "lte", "value": 30}],
"selection": {
"top_n": 20,
"hold_top_x": 20,
"allow_substitute": False,
"defer_buy": True,
},
"rebalance": "monthly",
"selection_interval_months": 6,
"rebalance_interval_months": 6,
"costs": {
"commission_rate": 0.0003,
"stamp_tax_rate": 0.0005,
"slippage_rate": 0.001,
"min_commission": 5,
},
"initial_capital": 1_000_000,
"period": ["2020-01-01", args.end],
}
print("[contract] POST /api/jobs(页面 submitJob 的请求体)…", flush=True)
job = _post("/api/jobs", spec)
job_id = job["job_id"]
print(f"[contract] job_id={job_id} status={job['status']}")
t0 = time.monotonic()
while True:
out = _get(f"/api/jobs/{job_id}")
if out["status"] in ("success", "failed", "cancelled"):
break
if time.monotonic() - t0 > 1800:
print("[contract] 超时")
return 1
time.sleep(3)
print(f"[contract] 终态 {out['status']},耗时 {time.monotonic() - t0:.0f}s")
if out["status"] != "success":
print(f"[contract] 失败:{out.get('error')}")
return 1
res = out["result"]
s = res["summary"]
# —— 页面 ResultView / BacktestMetrics 读取的字段 ——
required = [
"summary", "equity_curve", "drawdown", "monthly_returns", "yearly_returns",
"positions", "trades", "selection_history", "signal_history", "fills",
"symbol_curves", "factor_curves", "turnover_pct", "unimplemented", "config_snapshot",
]
missing = [k for k in required if k not in res]
assert not missing, f"结果缺少页面读取的字段:{missing}"
for k in (
"start", "end", "initial_capital", "final_equity", "total_return_pct",
"annual_return_pct", "sharpe", "max_drawdown_pct", "volatility_pct",
"win_rate_pct", "total_trades", "avg_turnover_pct",
):
assert k in s, f"summary 缺字段 {k}"
print(f"[contract] 指标:总收益 {s['total_return_pct']}% · 年化 {s['annual_return_pct']}%"
f" · 回撤 {s['max_drawdown_pct']}% · 成交 {s['total_trades']}")
# —— 整体收益趋势图 + 买卖点标注(page.tsx equityMarks 的数据源)——
equity_dates = {p["date"] for p in res["equity_curve"]}
assert equity_dates, "净值曲线为空"
buys = [a for a in res["fills"] if a["signal"] == "BUY"]
sells = [a for a in res["fills"] if a["signal"] == "SELL"]
assert buys and sells, f"买卖点为空:BUY={len(buys)} SELL={len(sells)}"
on_curve = sum(1 for a in buys + sells if a["date"] in equity_dates)
assert on_curve == len(buys) + len(sells), "存在落在净值曲线日期之外的买卖点(图上会丢失标注)"
print(f"[contract] 净值曲线 {len(equity_dates)} 点;买卖点 BUY={len(buys)} SELL={len(sells)}"
f"(全部可落到曲线日期上)")
# —— 个股收益率趋势图 + 买卖点(page.tsx SymbolCurveChart 的数据源)——
curves = res["symbol_curves"]
assert curves, "个股曲线为空"
curve = curves[0]
pt_dates = {p["date"] for p in curve["points"]}
assert curve["points"], f"{curve['symbol']} 曲线无数据点"
assert any(m["signal"] == "BUY" for m in curve["marks"]), "个股曲线缺 BUY 标注"
mark_dates = {m["date"] for m in curve["marks"]}
assert mark_dates <= pt_dates, f"个股买卖点日期不在曲线点上:{sorted(mark_dates - pt_dates)[:5]}"
assert all(m["filled"] for m in curve["marks"]), "个股 marks 含未成交记录"
print(f"[contract] 个股曲线 {len(curves)} 只;首只 {curve['symbol']} "
f"{len(curve['points'])} 点 / {len(curve['marks'])} 个买卖点,全部落点成功"
f"(期末 {curve['final_return_pct']}%)")
# —— 页面顶部 Pill 读取口径与 n/x ——
sel = res["config_snapshot"]["selection"]
basis = res["config_snapshot"]["price_basis"]
assert sel["top_n"] == 20 and sel["hold_top_x"] == 20, sel
assert basis["adjust_mode"] == "hfq", basis
print(f"[contract] config_snapshot.selection={sel}")
print(f"[contract] config_snapshot.price_basis={basis}")
# —— 买卖理由(「用数据说话」的字段契约)——
# 每个买卖点都必须带结构化理由,且关键数字(名次/综合分/因子值)齐全;
# 文案由后端词表生成,前端只展示 —— 这里断言的是「页面要读的字段真的在」。
KNOWN = {
"buy_enter_topn", "buy_defer_filled", "buy_skip_limit_up", "buy_skip_halted",
"buy_skip_no_cash", "buy_skip_min_commission", "sell_drop_topn", "sell_force_tmax",
"sell_defer_tmin", "sell_defer_halted", "sell_defer_limit_down",
}
missing_reason = [a for a in res["signal_history"] if not a.get("reason")]
assert not missing_reason, f"有买卖点没有理由:{missing_reason[:3]}"
bad_code = [a["reason"]["code"] for a in res["signal_history"] if a["reason"]["code"] not in KNOWN]
assert not bad_code, f"出现词表外的理由代码:{sorted(set(bad_code))}"
for a in res["signal_history"]:
r = a["reason"]
assert r["text"], f"{a['date']} {a['symbol']} 理由文案为空"
assert isinstance(r["data"], dict), f"{a['date']} {a['symbol']} 理由缺少结构化数据"
# 成交类理由必须能回答「第几名 / 多少候选 / 综合分 / 因子当时的值」
with_rank = [a for a in res["signal_history"] if isinstance(a["reason"]["data"].get("rank"), int)]
assert with_rank, "没有任何理由给出名次(名次是买入选股的核心依据)"
sample = next(a for a in res["signal_history"] if a["reason"]["code"] == "buy_enter_topn")
sd = sample["reason"]["data"]
assert sd["rank"] >= 1 and sd["total"] >= sd["rank"] and sd["top_n"] >= 1, sd
assert "score" in sd and sd.get("factors"), f"买入理由缺少综合分/因子值:{sd}"
print(f"[contract] 买卖理由 {len(res['signal_history'])} 条全部带理由与数字;样例 "
f"{sample['date']} {sample['symbol']} {sample['reason']['code']} "
f"rank={sd['rank']}/{sd['total']} score={sd['score']} factors={sd['factors']}")
# —— 成交明细两端的理由(页面「为什么买 / 为什么卖」两列)——
trades = res["trades"]
no_entry = [t for t in trades if not t.get("entry_reason")]
no_exit = [t for t in trades if not t.get("exit_reason")]
assert not no_entry, f"{len(no_entry)} 笔成交缺建仓理由"
assert not no_exit, f"{len(no_exit)} 笔成交缺卖出理由"
t0_ = trades[0]
print(f"[contract] 成交明细两端理由齐全({len(trades)} 笔);样例 {t0_['symbol']} "
f"买={t0_['entry_reason']['code']} 卖={t0_['exit_reason']['code']}")
# —— 因子曲线(页面「因子曲线」区块 + 放大页的数据源)——
fcs = res["factor_curves"]
assert fcs, "结果里没有因子曲线(页面会少一整块「买卖依据的水平」)"
for fc in fcs:
assert fc["name"] and fc["label"] and fc["direction"], fc
assert fc["points"], f"因子 {fc['name']} 曲线无数据点"
dates = [p_["date"] for p_ in fc["points"]]
assert dates == sorted(dates), f"因子 {fc['name']} 曲线日期未按时间升序"
assert len(set(dates)) == len(dates), f"因子 {fc['name']} 曲线有重复日期"
used = {f["name"] for f in spec["factors"]} | {"dividend_yield"}
names = {fc["name"] for fc in fcs}
assert names & used, f"因子曲线与本次策略用到的因子对不上:{names} vs {used}"
fc = fcs[0]
print(f"[contract] 因子曲线 {len(fcs)} 条;首条 {fc['name']}({fc['label']},"
f"单位 {fc.get('unit')},{len(fc['points'])} 点,方向 {fc['direction']})")
# —— 未成交意图卡片(signal_history 中 filled=False 且带原因)——
rejects = [a for a in res["signal_history"] if not a["filled"] and a["reject_reason"]]
print(f"[contract] 未成交意图 {len(rejects)} 条,样例:"
f"{rejects[0]['date'] if rejects else '—'} {rejects[0]['reject_reason'] if rejects else ''}")
print(f"[contract] unimplemented {len(res['unimplemented'])} 条(页面如实展示)")
print("[contract] ✅ 页面契约验证通过")
return 0
if __name__ == "__main__":
raise SystemExit(main())