Files
qlib/scripts/verify_backtest_page_contract.py
T
Simon 23972e7063 feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

153 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""前端页面契约验证:按回测页实际下发的请求体与读取路径校验后端字段,防结构漂移。
基准是页面参数模型的「高股息案例预设」(`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", "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}")
# —— 未成交意图卡片(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())