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 逐类验证归档页)。
This commit is contained in:
Simon
2026-09-20 07:31:04 +08:00
parent 7e15b7251e
commit 23972e7063
112 changed files with 17908 additions and 3893 deletions
+153
View File
@@ -0,0 +1,153 @@
"""前端页面契约验证:按回测页实际下发的请求体与读取路径校验后端字段,防结构漂移。
基准是页面参数模型的「高股息案例预设」(`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())