汇总三轮未提交的开发(每轮均在本机 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 逐类验证归档页)。
153 lines
6.6 KiB
Python
153 lines
6.6 KiB
Python
"""前端页面契约验证:按回测页实际下发的请求体与读取路径校验后端字段,防结构漂移。
|
||
|
||
基准是页面参数模型的「高股息案例预设」(`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()) |