Files
qlib/scripts/verify_strategy_workspace.py
Simon 36fe018075 docs+chore: 同步操作说明与端到端自检(字段库/单位换算、因子参数化)
- docs/USAGE.md:
  · 因子层一行改为「代码注册表投影 + 参数化实例,参数写在名字里以冻结口径」;
  · 新增 `GET/POST/PATCH /api/factors`、`GET /api/factors/templates` 与
    `/api/condition-fields`、`/fields` 的说明;
  · 新增「参数化因子(2026-10)」块:受控范围、键必须写全参数(缺项就靠可改的
    默认值兜底 = 追溯改义,所以拒绝)、口径文案按代码收敛、停用 ≠ 删除、
    参数化因子也能当过滤条件;
  · 自检清单一并更新(pytest 500 条;verify_strategy_workspace 145 项 skip-job;
    verify_ui_alignment 8 页 160 项;新增 verify_unit_conversion、verify_factor_params)。
- scripts/:新增 verify_unit_conversion.py(单位只能在给定范围里选 + 界面单位⇄
  基准单位换算)、verify_factor_params.py(参数暴露/界面新建/越界拒绝/停用语义,
  跑完自动清掉临时因子);verify_strategy_workspace.py 加 [5.7b] 因子参数化一节,
  临时因子的清理挪进 finally(断言中途失败也不给真人库留垃圾)。
- .gitignore:docs/screenshots/ 是临时验证证据,不入库(文件留在磁盘)。
2026-10-01 16:38:54 +08:00

762 lines
43 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.
#!/usr/bin/env python
"""策略研究工作台契约自检(本轮新增能力的端到端验证)。
覆盖「创建策略 → 展开 → 回测 → 选股 → 对比」全链路,以及三处前端契约:
- `GET /api/stocks/names`:前端全站股票名称缓存的唯一数据源(形状必须是 {symbol: name});
- `POST /api/strategies`:说明为空时必须自动补全(需求:策略必须有说明);
- `PUT /api/strategies/{id}`:原地更新且 id/created_at 不变(策略库「编辑」依赖);
- `GET /api/strategies/{id}/describe`、`POST /api/strategies/describe`:说明 + 计算公式;
- 回测结果里 `symbol_curves/positions/trades` 必须带 `name`(前端「代码必须配名称」依赖);
- 选股结果 `candidates[].name` 与 `config_snapshot`(选股 → 回测直通依赖它取回当时的规则);
- 页面 SSR:/strategies、/backtest、/fields、/experiments 必须 200 且含关键区块;
- **归档链路**:`GET /api/experiments` 的 `X-Total-Count` 与 kind/q 过滤、归档详情含
`data_version`/`job_id`、`/experiments/{id}` 归档页 SSR 能渲染、`DELETE` 语义正确。
用法:
cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py
# 跳过长回测(只验接口与页面):
... --skip-job
"""
from __future__ import annotations
import argparse
import json
import time
import urllib.error
import urllib.request
API = "http://127.0.0.1:8000"
WEB = "http://127.0.0.1:3000"
_ok = 0
_bad = 0
def check(cond: bool, label: str, detail: str = "") -> None:
global _ok, _bad
if cond:
_ok += 1
print(f" ✅ {label}" + (f" — {detail}" if detail else ""), flush=True)
else:
_bad += 1
print(f" ❌ {label}" + (f" — {detail}" if detail else ""), flush=True)
def call_raw(method: str, path: str, body: object | None = None, timeout: float = 60.0):
"""返回 (status, headers, payload):需要读响应头(X-Total-Count 等)时用。"""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
API + path, data=data, method=method, headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, {k.lower(): v for k, v in r.headers.items()}, json.loads(
r.read().decode() or "null"
)
except urllib.error.HTTPError as e:
return e.code, {}, e.read().decode()[:300]
def call(method: str, path: str, body: object | None = None, timeout: float = 60.0):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
API + path, data=data, method=method, headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read().decode() or "null")
except urllib.error.HTTPError as e:
return e.code, e.read().decode()[:300]
def detail_of(payload) -> str:
"""从响应里取人类可读的错误说明。
`call()` 在 HTTPError 分支返回的是**原文串**(不是 dict),所以这里必须兼容两种形态 ——
直接对 str 调 .get() 会把自检脚本自己搞崩(真实踩过)。
"""
if isinstance(payload, dict):
return str(payload.get("detail") or payload)
return str(payload)
def get_text(url: str, timeout: float = 120.0) -> tuple[int, str]:
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.status, r.read().decode("utf-8", "ignore")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "ignore")[:200]
# 选股策略(2026-09 重构后只含「怎么选」;资金/持仓/调仓/费率/区间移到回测组合与公共配置)
STRATEGY = {
"name": f"契约自检-高股息-{int(time.time())}",
"description": "", # 故意留空:验证后端自动补全
"universe": {"exclude_st": True, "min_listing_days": 250},
"factors": [{"name": "dividend_yield", "weight": 1}],
"conditions": [{"field": "dv_ratio", "op": "lte", "value": 30}],
}
# 回测组合(引用上面的选股策略 + 回测参数)—— 用于 [5] 的组合回测 Job
COMBO = {
"name": f"契约自检-组合-{int(time.time())}",
"strategy_ids": [], # 创建策略后回填
"initial_capital": 1000000,
"hold_count": 15,
"hold_min_days": 0,
"hold_max_days": 20,
"rebalance_freq": "monthly",
"period": ["2024-01-02", "2024-12-31"],
}
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--end", default="2024-12-31", help="回测结束日(默认 1 年,控制在 ~2 分钟)")
p.add_argument("--skip-job", action="store_true", help="跳过真实回测 Job(只验接口与页面)")
args = p.parse_args()
created_id = ""
try:
# ---------- 1. 名称接口 ----------
print("[1] 股票名称接口(全站名称缓存的唯一数据源)", flush=True)
st, names = call("GET", "/api/stocks/names")
check(st == 200 and isinstance(names, dict), "GET /api/stocks/names 返回 dict", f"HTTP {st}")
if isinstance(names, dict):
check(len(names) > 5000, "名称条数 > 5000", f"{len(names)} 条")
check(names.get("600519.SH") == "贵州茅台", "含 600519.SH 贵州茅台", str(names.get("600519.SH")))
st2, one = call("GET", "/api/stocks/600519.SH")
check(st2 == 200, "GET /api/stocks/{symbol} 未被 /names 抢占(路由顺序)", f"HTTP {st2}")
# ---------- 2. 创建策略(说明自动补全) ----------
print("[2] 创建策略 + 说明自动补全", flush=True)
st, saved = call("POST", "/api/strategies", STRATEGY)
check(st == 200 and isinstance(saved, dict), "POST /api/strategies", f"HTTP {st}")
if not isinstance(saved, dict):
return 1
created_id = saved.get("id") or ""
check(bool(created_id), "返回策略 id", created_id)
check(
bool((saved.get("description") or "").strip()),
"说明为空时被自动补全(策略必须有说明)",
(saved.get("description") or "")[:80],
)
# 选股策略不应持久化任何回测执行参数(重构核心约束)
for forbidden in ("selection_interval_months", "rebalance_interval_months", "selection", "costs", "rebalance"):
check(
forbidden not in saved,
f"选股策略不存回测参数字段 {forbidden}",
str(saved.get(forbidden)),
)
created_at = str(saved.get("created_at") or "")
# ---------- 3. 原地更新 ----------
print("[3] 原地更新(策略库「编辑」依赖)", flush=True)
upd = dict(STRATEGY)
upd["conditions"] = [{"field": "dv_ratio", "op": "lt", "value": 15}]
st, after = call("PUT", f"/api/strategies/{created_id}", upd)
check(st == 200, "PUT /api/strategies/{id}", f"HTTP {st}")
if isinstance(after, dict):
check(after.get("id") == created_id, "id 不变", str(after.get("id")))
conds = after.get("conditions") or []
check(
len(conds) == 1 and conds[0].get("value") == 15,
"条件已更新(dv_ratio < 15)",
str(conds),
)
# 选股策略不应再带回测执行参数字段(重构核心约束)
for forbidden in ("selection", "costs", "rebalance", "price_adjustment"):
check(forbidden not in after, f"选股策略不含回测参数字段 {forbidden}", str(after.get(forbidden)))
check(
str(after.get("created_at") or "") == created_at,
"created_at 未被刷新(避免「改一下就排最前」)",
f"{created_at} → {after.get('created_at')}",
)
st, notfound = call("PUT", "/api/strategies/STG-NOT-EXIST", upd)
check(st == 404, "更新不存在的策略 → 404", f"HTTP {st}")
# 改名目标必须**每次运行都不同**:曾经用固定名字,脚本被中断(未走到 cleanup)时
# 会留下同名策略,导致下次运行在这里收到正确的 400 重名拒绝、却被误判为失败。
rename_to = f"契约自检-改名-{int(time.time())}"
st, dup = call("PUT", f"/api/strategies/{created_id}", {**upd, "name": rename_to})
check(st == 200, "改名成功(未撞车)", f"HTTP {st} → {rename_to}")
# 重名应当被拒(400),这是产品行为,必须验到
st, conflict = call("PUT", f"/api/strategies/{created_id}", {**upd, "name": "高股息 Top20(案例口径)"})
check(st == 400, "改成已存在的策略名 → 400(重名保护)", f"HTTP {st}")
# ---------- 4. 说明与公式 ----------
print("[4] 说明 / 计算公式(describe_strategy)", flush=True)
st, doc = call("GET", f"/api/strategies/{created_id}/describe")
check(st == 200 and isinstance(doc, dict), "GET /strategies/{id}/describe", f"HTTP {st}")
if isinstance(doc, dict):
summary = doc.get("summary") or ""
formula = doc.get("formula") or ""
check(bool(summary), "summary 非空(一句话说明)", summary[:90])
check("dividend_yield" in formula, "公式含因子名", "dividend_yield")
check("dv_ratio" in formula, "公式含过滤条件字段", "dv_ratio")
check(
"dv_ratio" in formula or "≤" in formula or "<=" in formula or "30" in formula,
"说明体现过滤条件",
formula[:80].replace("\n", " "),
)
check(
"选股" in summary or "因子" in formula,
"说明体现选股口径(选股策略不讲成本/成交,那些在回测组合里)",
summary[:60],
)
check(bool(doc.get("steps")), "steps 非空(执行步骤)", f"{len(doc.get('steps') or [])} 步")
spec_probe = {
"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}],
# 注意:allow_substitute 与 defer_buy 互斥;只给 defer_buy 会因后端默认
# allow_substitute=True 触发 422(这正是在前端表单里用三态单选表达的原因)
"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, "min_commission": 5},
"initial_capital": 1000000,
"period": ["2020-01-01", args.end],
}
st, doc2 = call("POST", "/api/strategies/describe", spec_probe)
check(st == 200 and bool((doc2 or {}).get("formula")), "POST /strategies/describe(未保存参数也可预览)", f"HTTP {st}")
# 互斥校验必须仍然生效(前端三态单选正是为避免踩到它)
bad = dict(spec_probe)
bad["selection"] = {"top_n": 20, "allow_substitute": True, "defer_buy": True}
st, _ = call("POST", "/api/strategies/describe", bad)
check(st == 422, "allow_substitute 与 defer_buy 同真被拒(前端用三态单选规避)", f"HTTP {st}")
# ---------- 5. 回测组合(取代旧的 /expand) ----------
print("[5] 回测组合" + ("" if args.skip_job else " + 真实组合回测 Job"), flush=True)
# /expand 已随重构移除(回测改由组合驱动)
st, _ = call(
"POST",
f"/api/strategies/{created_id}/expand",
{"period": ["2024-01-02", args.end], "initial_capital": 1000000},
)
check(st in (404, 405), "/strategies/{id}/expand 已移除(回测改走 /api/combos)", f"HTTP {st}")
combo = dict(COMBO)
combo["strategy_ids"] = [created_id]
combo["period"] = ["2024-01-02", args.end]
st, saved_combo = call("POST", "/api/combos", combo)
check(st == 200 and isinstance(saved_combo, dict), "POST /api/combos(保存组合)", f"HTTP {st}")
combo_id = (saved_combo or {}).get("id") if isinstance(saved_combo, dict) else None
check(bool(combo_id), "返回组合 id", str(combo_id))
# 组合库(列表 / 详情 / 原地更新)—— 前端「已保存的回测组合」卡片依赖这些读路径
st, combo_list = call("GET", "/api/combos")
check(st == 200 and isinstance(combo_list, list), "GET /api/combos(组合库列表)", f"HTTP {st}")
if isinstance(combo_list, list) and combo_id:
check(any(c.get("id") == combo_id for c in combo_list), "列表含刚保存的组合", str(combo_id))
st, detail = call("GET", f"/api/combos/{combo_id}")
check(
st == 200 and (detail or {}).get("hold_count") == COMBO["hold_count"],
"GET /api/combos/{id} 回读参数一致",
f"HTTP {st}",
)
# 原地更新:description 往返(前端「载入到表单 → 改 → 更新组合」链路)
upd_combo = {**combo, "description": "契约自检:更新后的组合说明", "hold_count": 8}
st, updated = call("PUT", f"/api/combos/{combo_id}", upd_combo)
check(
st == 200 and (updated or {}).get("description") == "契约自检:更新后的组合说明",
"PUT /api/combos/{id} 说明可往返(组合库编辑依赖)",
f"HTTP {st}",
)
check(
(updated or {}).get("hold_count") == 8,
"PUT 更新持仓数生效(不是静默忽略)",
str((updated or {}).get("hold_count")),
)
# 未知字段必须 422:拼错键名被静默忽略 = 用户以为设上了、其实没生效(AGENT 禁止降级)
st, _ = call("POST", "/api/combos/run", {**combo, "capital": 1})
check(st == 422, "组合含未知字段 → 422(不静默降级)", f"HTTP {st}")
st, _ = call("POST", "/api/strategies", {**STRATEGY, "costs": {"commission_rate": 0.001}})
check(st == 422, "策略含旧版回测参数 → 422(不静默丢弃)", f"HTTP {st}")
# ---------- 5.5 公共配置(全局唯一) ----------
print("[5.5] 公共配置(费率/印花税/滑点/复权,回测组合运行时快照)", flush=True)
st, cfg = call("GET", "/api/config")
check(st == 200 and isinstance(cfg, dict), "GET /api/config", f"HTTP {st}")
if isinstance(cfg, dict):
check(cfg.get("id") == "default", "公共配置是单例(id=default)", str(cfg.get("id")))
for key in (
"commission_rate", "stamp_tax_rate", "slippage_rate",
"min_commission", "price_adjustment", "benchmark",
):
check(key in cfg, f"公共配置含 {key}", str(cfg.get(key)))
st, _ = call("PUT", "/api/config", {"slippage": 0.001}) # 故意写错键名:必须被拒
check(st == 422, "公共配置含未知字段 → 422(拼错键名不静默忽略)", f"HTTP {st}")
# 策略库说明不得再出现回测执行词:旧自动文案(「每 6 个月调仓…含佣金…」)已被
# c5d6e7f8a9b0 重算,这里做数据层回归守卫,防止再出现自相矛盾的策略说明。
_, all_st = call("GET", "/api/strategies")
stale_desc = [
(s or {}).get("id")
for s in (all_st if isinstance(all_st, list) else [])
if any(
m in ((s or {}).get("description") or "")
for m in ("佣金", "印花税", "滑点", "调仓", "复权口径")
)
]
check(not stale_desc, "策略库无陈旧说明(不含调仓/成本词)", str(stale_desc))
# ---------- 5.6 字段库(过滤条件的字段目录,2026-10) ----------
print("[5.6] 字段库(条件字段目录:中文名/含义/类型收窄/自定义增删)", flush=True)
st, fields = call("GET", "/api/condition-fields")
check(st == 200 and isinstance(fields, list) and fields, "GET /api/condition-fields(首次读取自动 seed)", f"HTTP {st},{len(fields) if isinstance(fields, list) else '?'} 条")
by_name = {f.get("name"): f for f in (fields if isinstance(fields, list) else [])}
check("dv_ratio" in by_name, "字段库含 dv_ratio", str(sorted(by_name)[:5]))
for key in ("close", "ma60", "static.industry", "fundamental.roe", "dividend_yield"):
check(key in by_name, f"字段库含 {key}", "缺失")
dv = by_name.get("dv_ratio") or {}
check(bool(dv.get("label")) and bool(dv.get("description")), "字段有中文名与含义(下拉要显示)", f"{dv.get('label')} / {(dv.get('description') or '')[:24]}")
check(dv.get("kind") == "num" and dv.get("unit") == "%", "字段带类型与单位", f"{dv.get('kind')} {dv.get('unit')}")
ind = by_name.get("static.industry") or {}
check(
set(ind.get("ops") or []) == {"eq", "ne", "in", "not_in"},
"文本字段比较符收窄为 等值/集合(不摆出恒为假的 >)",
str(ind.get("ops")),
)
check(
set(dv.get("ops") or []) == {"gt", "gte", "lt", "lte", "eq", "ne"},
"数值字段比较符合法集合",
str(dv.get("ops")),
)
st, avail = call("GET", "/api/condition-fields/available")
check(st == 200 and isinstance(avail, list), "GET /api/condition-fields/available", f"HTTP {st},{len(avail) if isinstance(avail, list) else '?'} 条")
# 单位:只能从注册表给的阶梯里选(界面单位),基准单位不可改 —— 换算在界面层做,
# 库里/引擎里永远是基准单位,所以改单位不会让历史策略变义。
mv = by_name.get("total_mv") or {}
check(mv.get("base_unit") == "万元", "字段响应带基准单位 base_unit(引擎口径)", str(mv.get("base_unit")))
check(
[(u.get("unit"), u.get("factor")) for u in (mv.get("units") or [])] == [("万元", 1.0), ("亿元", 10000.0)],
"总市值的可选界面单位 = 万元/亿元(系数 1/10000)",
str(mv.get("units")),
)
check(
[(u.get("unit"), u.get("factor")) for u in (by_name.get("close") or {}).get("units") or []]
== [("元", 1.0)],
"没有备选单位的字段只有基准单位一项(界面不给选择)",
str((by_name.get("close") or {}).get("units")),
)
st, _ = call("PUT", "/api/condition-fields/total_mv", {"unit": "亿亿元"})
check(st == 422, "自由文本单位 → 422(单位只在你给的范围内选)", f"HTTP {st}")
st, _ = call("PUT", "/api/condition-fields/total_mv", {"unit": "元"})
check(st == 422, "不在该字段阶梯里的单位 → 422(总市值没有「元」这一档)", f"HTTP {st}")
st, unit_case = call("PUT", "/api/condition-fields/total_mv", {"unit": "亿元"})
check(
st == 200 and (unit_case or {}).get("unit") == "亿元" and (unit_case or {}).get("base_unit") == "万元",
"选界面单位 亿元:生效,且基准单位仍是 万元",
f"HTTP {st} unit={(unit_case or {}).get('unit')} base={(unit_case or {}).get('base_unit')}",
)
st, _ = call("PUT", "/api/condition-fields/total_mv", {"unit": "万元"})
check(st == 200, "单位改回基准单位 万元(自检收尾,不留痕)", f"HTTP {st}")
st, _ = call("POST", "/api/condition-fields", {"name": "static.list_date"})
check(st == 422, "新增不可计算的字段 → 422(拒绝伪字段,避免永远选不出股票)", f"HTTP {st}")
st, made = call("POST", "/api/condition-fields", {"name": "ps", "label": "自检-市销率"})
check(st == 200 and (made or {}).get("source") == "custom", "新增自定义字段(引擎支持的少用字段)", f"HTTP {st}")
st, _ = call("POST", "/api/condition-fields", {"name": "ps"})
check(st == 422, "重复新增 → 422(不静默覆盖)", f"HTTP {st}")
st, _ = call("PUT", "/api/condition-fields/ps", {"label": "自检-改名"})
check(st == 200, "编辑中文名/含义", f"HTTP {st}")
st, _ = call("PUT", "/api/condition-fields/ps", {"kind": "str"})
check(st == 422, "改 kind → 422(类型是引擎事实,不许改)", f"HTTP {st}")
st, _ = call("PUT", "/api/condition-fields/ps", {"enabled": False})
check(st == 200, "停用字段", f"HTTP {st}")
_, picker = call("GET", "/api/condition-fields?include_disabled=false")
check(
"ps" not in {f.get("name") for f in (picker if isinstance(picker, list) else [])},
"停用后不出现在条件选择器里",
"",
)
st, _ = call("DELETE", "/api/condition-fields/close")
check(st == 400, "删除内置字段 → 400(只能停用,否则下次读取又补回来)", f"HTTP {st}")
st, _ = call("DELETE", "/api/condition-fields/ps")
check(st == 200, "删除自定义字段", f"HTTP {st}")
# ---------- 5.7 因子目录(必须是代码注册表的投影,不是可编辑配置) ----------
print("[5.7] 因子目录(注册表投影:口径/方向/回看/依赖列与代码一致)", flush=True)
st, factors = call("GET", "/api/factors")
check(st == 200 and isinstance(factors, list) and factors, "GET /api/factors", f"HTTP {st}")
cat = {f.get("name"): f for f in (factors if isinstance(factors, list) else [])}
check("dividend_yield" in cat, "目录含股息率因子(历史 bug:后加的因子曾长期缺失)", str(sorted(cat)))
try:
from app.quant.factors import (
list_factors as _registry, # noqa: PLC0415 - 自检可用时才导入
)
drift = []
for d in _registry():
row = cat.get(d.name)
if row is None:
drift.append(f"{d.name}: 缺失")
continue
for k in ("description", "formula", "brief", "frequency", "lookback", "direction", "requires"):
want, got = getattr(d, k), row.get(k)
# requires 在库里是 JSON 数组、代码里是 tuple:比语义,不比容器类型
same = list(got or []) == list(want or []) if k == "requires" else got == want
if not same:
drift.append(f"{d.name}.{k}: 库={got!r} 代码={want!r}")
check(not drift, "目录与代码注册表逐字段一致(方向/口径/依赖列不许漂移)", "; ".join(drift[:3]))
except ImportError as e: # 没装后端依赖时如实跳过,不假装通过
print(f" ⚠ 跳过注册表比对({e}):请用 PYTHONPATH=. 在 backend 下运行", flush=True)
# ---------- 5.7b 因子参数化(窗口/方向可编辑,且真生效、可冻结) ----------
print("[5.7b] 因子参数化(模板 + 受控参数 + 参数化键 = 冻结口径)", flush=True)
mv = cat.get("momentum_60") or {}
check(
mv.get("label") == "动量(窗口 60,越高越好)",
"目录暴露中文名(含真实参数)",
str(mv.get("label")),
)
check(
(mv.get("params") or {}).get("window") == 60 and mv.get("template") == "momentum",
"目录暴露 template 与冻结参数",
f"template={mv.get('template')} params={mv.get('params')}",
)
specs = {s.get("name"): s for s in (mv.get("param_specs") or [])}
check(
specs.get("window", {}).get("maximum") == 500
and specs.get("window", {}).get("minimum") == 2
and specs.get("direction", {}).get("choices") == ["higher_is_better", "lower_is_better"],
"暴露可编辑参数与允许范围(窗口 2~500、方向二选一)",
f"window={specs.get('window')} direction={specs.get('direction')}",
)
probe_key = "momentum(window=91,direction=lower_is_better)"
try:
st, tpls = call("GET", "/api/factors/templates")
tpl_names = {t.get("name") for t in (tpls or [])} if isinstance(tpls, list) else set()
check(
st == 200 and {"momentum", "volatility", "volume_ratio", "dividend_yield"} <= tpl_names,
"GET /api/factors/templates(模板:可编辑参数与默认值)",
f"HTTP {st} {sorted(tpl_names)[:4]}",
)
st, made = call("POST", "/api/factors", {"template": "momentum", "params": {"window": 91, "direction": "lower_is_better"}})
created_key = (made or {}).get("name") if isinstance(made, dict) else None
check(st == 201 and created_key == probe_key, "POST /api/factors 新建参数化因子(参数写进名字)", f"HTTP {st} {created_key}")
check(
(made or {}).get("label") == "动量(窗口 91,越低越好)"
and (made or {}).get("lookback") == 91
and (made or {}).get("source") == "custom",
"新建行带回中文名/回看/来源(口径由引擎投影)",
f"{made.get('label') if isinstance(made, dict) else made}",
)
st, dup = call("POST", "/api/factors", {"template": "momentum", "params": {"window": 91, "direction": "lower_is_better"}})
check(st == 422 and "已存在" in detail_of(dup), "同参数不重复创建(422 + 已存在的名字)", f"HTTP {st} {detail_of(dup)[:60]}")
for bad_params, why in (
({"window": 999, "direction": "higher_is_better"}, "窗口越界"),
({"window": 90, "direction": "upper"}, "方向不在枚举里"),
({"window": 90, "direction": "higher_is_better", "foo": 1}, "多给了参数"),
):
st, bad = call("POST", "/api/factors", {"template": "momentum", "params": bad_params})
check(st == 422, f"越界/非法参数被拒:{why}", f"HTTP {st} {detail_of(bad)[:50]}")
st, bad_tpl = call("POST", "/api/factors", {"template": "no_such", "params": {}})
check(st == 422 and "未知因子模板" in detail_of(bad_tpl), "未知模板被拒", f"HTTP {st}")
# 参数真的进了引擎:lookback 与方向都要跟着变
try:
# 别名不要叫 _ok:本脚本的通过计数器就叫 _ok,遮蔽它会打印出函数对象
from app.quant.factors import get_factor as _gf # noqa: PLC0415
from app.quant.factors import is_resolvable as _resolvable # noqa: PLC0415
d91, _ = _gf(probe_key)
check(
d91.lookback == 91 and d91.direction == "lower_is_better" and "91" in d91.formula,
"参数化键在引擎里真解析(回看/方向/公式都带上了 91)",
f"lookback={d91.lookback} dir={d91.direction}",
)
check(
_resolvable("momentum(window=91)") is False,
"缺参数的短键被拒(不许靠模板默认值兜底)",
)
check(
_resolvable("momentum_20(window=5,direction=higher_is_better)") is False,
"实例名不能再带参数",
)
except ImportError as e:
print(f" ⚠ 跳过引擎解析比对({e})", flush=True)
# 参数化因子能当过滤条件(字段库取得到、无单位),并能被策略引用后回读
try:
from app.quant.condition_fields import get_field as _getf # noqa: PLC0415
fdef = _getf(probe_key)
check(
fdef is not None and fdef.kind == "num" and fdef.unit == "",
"参数化因子可当过滤条件(kind=num 且不瞎挂单位)",
f"{fdef}",
)
except ImportError as e:
print(f" ⚠ 跳过条件字段比对({e})", flush=True)
st, probe_st2 = call("POST", "/api/strategies", {
**STRATEGY,
"name": f"契约自检-参数化因子-{int(time.time())}",
"factors": [{"name": probe_key, "weight": 1}],
"conditions": [],
})
probe2_id = (probe_st2 or {}).get("id") if isinstance(probe_st2, dict) else None
check(st == 200 and probe2_id, "参数化因子能被策略引用并落库", f"HTTP {st}")
if probe2_id:
_, back = call("GET", f"/api/strategies/{probe2_id}")
got_f = ((back or {}).get("factors") or [{}])[0].get("name")
check(got_f == probe_key, "回读策略:因子名(含参数)原样存回(历史不变义)", str(got_f))
_, doc = call("GET", f"/api/strategies/{probe2_id}/describe")
doc_text = "\n".join(
[str((doc or {}).get("formula") or "")]
+ [str(x) for x in ((doc or {}).get("steps") or [])]
)
check(probe_key in doc_text, "策略说明书写明所用参数版本(读者要知道窗口是多少)", doc_text[:80])
pd2, _ = call("DELETE", f"/api/strategies/{probe2_id}")
print(f"[cleanup] 删除参数化因子自检策略 {probe2_id} → HTTP {pd2}", flush=True)
st, off = call("PATCH", "/api/factors", {"name": probe_key, "enabled": False})
check(st == 200 and (off or {}).get("enabled") is False, "PATCH 停用参数化因子", f"HTTP {st}")
_, again = call("GET", "/api/factors")
still = {f.get("name"): f.get("enabled") for f in (again or []) if isinstance(f, dict)}
check(still.get(probe_key) is False, "停用状态不被幂等同步冲掉", str(still.get(probe_key)))
st, on = call("PATCH", "/api/factors", {"name": probe_key, "enabled": True})
check(st == 200 and (on or {}).get("enabled") is True, "PATCH 启用(复原)", f"HTTP {st}")
st, bi = call("PATCH", "/api/factors", {"name": "momentum_60", "enabled": False})
check(st == 422, "内置实例不能停用(开关由代码决定)", f"HTTP {st}")
# (清理统一放在 finally 里,见下方:断言中途失败也不会留垃圾)
finally:
# 清理:目录没有删除接口(设计如此),按主键直接清;放在 finally 里,
# 即使上面的断言抛错也不会给真人库留下「自检因子」
try:
from app.infrastructure.persistence.sqlalchemy.models.factor import ( # noqa: PLC0415
FactorDefinitionModel as _M,
)
from app.infrastructure.persistence.sqlalchemy.session import ( # noqa: PLC0415
SessionLocal as _SL,
)
from sqlalchemy import delete as _delete # noqa: PLC0415
with _SL() as _s:
_s.execute(_delete(_M).where(_M.name == probe_key))
_s.commit()
print(f"[cleanup] 删除临时参数化因子 {probe_key}", flush=True)
except Exception as e: # noqa: BLE001 - 清理失败要显式说出来,别静默留着
print(f" ⚠ 清理临时因子失败(请手工检查 factor_definition):{e}", flush=True)
# 条件payload的两种形态必须能被服务端接受(ref 字段比较 / in 文本集合)
st, probe_st = call("POST", "/api/strategies", {
**STRATEGY,
"name": f"契约自检-条件形态-{int(time.time())}",
"conditions": [{"field": "close", "op": "gt", "ref": "ma60"},
{"field": "static.industry", "op": "in", "value": ["银行"]}],
})
check(st == 200, "条件支持「字段 vs 字段」(close>ma60) 与「属于」文本集合", f"HTTP {st}")
if st == 200 and isinstance(probe_st, dict) and probe_st.get("id"):
pd, _ = call("DELETE", f"/api/strategies/{probe_st['id']}")
print(f"[cleanup] 删除条件形态自检策略 {probe_st.get('id')} → HTTP {pd}", flush=True)
if not args.skip_job and combo_id:
st, job = call("POST", f"/api/combos/{combo_id}/run")
check(st == 200, "POST /api/combos/{id}/run(组合 → 回测 Job)", f"HTTP {st}")
job_id = (job or {}).get("job_id") if isinstance(job, dict) else None
if job_id:
t0 = time.monotonic()
out = None
while time.monotonic() - t0 < 900:
_, out = call("GET", f"/api/jobs/{job_id}")
if (out or {}).get("status") in ("success", "failed", "cancelled"):
break
time.sleep(6)
status = (out or {}).get("status")
check(status == "success", f"回测 Job 终态 success(耗时 {time.monotonic()-t0:.0f}s)", str(status))
res = ((out or {}).get("result") or {}) if isinstance(out, dict) else {}
curves = res.get("symbol_curves") or []
pos = res.get("positions") or []
trades = res.get("trades") or []
check(bool(curves), "结果含个股曲线", f"{len(curves)} 条")
check(
all(c.get("name") for c in curves[:5]),
"symbol_curves[].name 已填充(前端代码必须配名称)",
str([c.get("name") for c in curves[:3]]),
)
check(
bool(pos) and all(p.get("name") for p in pos[:5]),
"positions[].name 已填充",
str([p.get("name") for p in pos[:3]]),
)
check(
all(t.get("name") for t in trades[:5]),
"trades[].name 已填充",
str([t.get("name") for t in trades[:3]]),
)
# ---------- 6. 选股 + 直通契约 ----------
print("[6] 选股结果的 name 与「选股 → 回测」直通契约", flush=True)
st, sel = call(
"POST",
"/api/selections",
{
"universe": {"exclude_st": True, "min_listing_days": 250},
"as_of": "2024-07-01",
"method": "score",
"factors": [{"name": "dividend_yield", "weight": 1}],
"conditions": [{"field": "dv_ratio", "op": "lte", "value": 30}],
"top_n": 20,
},
timeout=300,
)
check(st == 200 and isinstance(sel, dict), "POST /api/selections", f"HTTP {st}")
if isinstance(sel, dict):
run = sel.get("result", sel)
cands = run.get("candidates") or []
check(bool(cands), "选股返回候选", f"{len(cands)} 只")
check(
all(c.get("name") for c in cands[:5]),
"candidates[].name 已填充",
str([c.get("name") for c in cands[:3]]),
)
snap = run.get("config_snapshot") or {}
check(
bool(snap.get("factors")) and "top_n" in snap and "conditions" in snap,
"config_snapshot 含规则(回测页据此预填参数)",
f"keys={sorted(snap.keys())[:6]}",
)
sid = sel.get("selection_id")
if sid:
st, again = call("GET", f"/api/selections/{sid}")
check(st == 200, "GET /selections/{id} 可读回(直通按钮依赖)", f"HTTP {st}")
# ---------- 6.5 归档(存档)链路 ----------
print("[6.5] 回测存档:列表过滤 / 总数 / 详情元数据 / 归档页 / 删除", flush=True)
st, exps = call("GET", "/api/experiments?limit=200")
check(st == 200 and isinstance(exps, list), "GET /api/experiments 列表", f"HTTP {st}")
check(bool(exps), "已有归档记录", f"{len(exps) if isinstance(exps, list) else 0} 条")
exp_id = ""
if isinstance(exps, list) and exps:
exp_id = exps[0].get("id") or ""
st, headers, _ = call_raw("GET", "/api/experiments?limit=1")
total = headers.get("x-total-count")
check(
total is not None and total.isdigit() and int(total) >= 1,
"列表通过 X-Total-Count 暴露总数(不再静默截断在 50)",
f"X-Total-Count={total}",
)
st, only_bt = call("GET", "/api/experiments?kind=backtest&limit=200")
check(
st == 200 and isinstance(only_bt, list) and all(e.get("kind") == "backtest" for e in only_bt),
"kind=backtest 过滤生效",
f"{len(only_bt) if isinstance(only_bt, list) else '?'} 条",
)
st, none_hit = call("GET", "/api/experiments?q=zzz-no-such-experiment")
check(
st == 200 and isinstance(none_hit, list) and len(none_hit) == 0,
"q= 过滤生效(不存在的关键词 → 0 条)",
f"{len(none_hit) if isinstance(none_hit, list) else '?'} 条",
)
if exp_id:
st, det = call("GET", f"/api/experiments/{exp_id}")
check(st == 200 and isinstance(det, dict), f"GET /api/experiments/{exp_id} 详情", f"HTTP {st}")
if isinstance(det, dict):
check("job_id" in det, "详情含来源作业 id", str(det.get("job_id")))
check("data_version" in det, "详情含数据快照指纹字段", str(det.get("data_version")))
check(bool(det.get("spec")), "详情含归档 spec(复现依据)")
res = det.get("result") or {}
meta = res.get("archive_meta") or {}
if meta:
check(
meta.get("curves_total") is not None,
"结果含 archive_meta(曲线存储完整度)",
f"stored={meta.get('curves_stored')} total={meta.get('curves_total')} "
f"truncated={meta.get('truncated')}",
)
check(
not meta.get("truncated") or bool(meta.get("curves_stored")),
"若被裁剪则如实标注(不静默丢曲线)",
str(meta.get("truncated")),
)
else:
check(False, "结果含 archive_meta(曲线存储完整度)", "缺失:归档未带完整度元数据")
# ---------- 7. 页面 SSR ----------
print("[7] 页面可访问性与关键区块", flush=True)
pages = [
("/strategies", ["选股策略库", "新建选股策略", "股票池"]),
# 注意:只验 SSR 就能看到的静态文案。「直接运行/载入到表单」是组合列表
# 非空时客户端渲染出来的按钮,SSR 阶段列表还在加载,断言它们会假失败。
("/backtest", ["回测组合", "选择选股策略", "持仓数 N", "已保存的回测组合"]),
("/settings", ["公共配置", "交易成本与行情口径", "全局唯一"]),
# 字段库:说明卡是 SSR 静态内容,可断言;字段表格由客户端拉取后渲染
("/fields", ["字段库", "打分因子 与 过滤条件 是什么关系", "新增字段"]),
# 因子研究:说明「参数可改(新建参数化因子)、口径按代码收敛」必须是 SSR 文案 ——
# 参数化之前这里写的是「不可修改」,那句话现在已经不成立(不能留假话)
("/factors", ["因子目录", "投影", "参数", "新建参数化因子"]),
("/experiments", ["实验对比", "参数"]),
]
if exp_id:
# 归档页必须能回答「选股条件」与「交易执行依据」——这是本页存在的理由
pages.append((f"/experiments/{exp_id}", ["选股条件", "交易执行依据", "归档"]))
for path, keywords in pages:
code, html = get_text(WEB + path)
check(code == 200, f"{path} HTTP 200", str(code))
missing = [k for k in keywords if k not in html]
check(not missing, f"{path} 含关键区块", f"缺失 {missing}" if missing else "全部命中")
# ---------- 7.1 归档页按类型逐类验证 ----------
# 为什么单列一节:归档结果的**结构随 kind 变化**(backtest / factor_test / selection),
# 只验回测归档会漏掉「非回测归档按回测字段渲染 → 整页 500」这类问题(真实踩过:
# 4 条 factor_test + 1 条 selection 归档从列表点进去全部 500)。这里对库里
# **每一种**出现的归档类型各取一条真实归档验证:必须 200,且非回测类型不得
# 出现回测专属区块(净值曲线 / 交易执行依据),必须出现该类型自己的区块。
print("[7.1] 归档页按 kind 逐类验证(防结构错配 500)", flush=True)
_, listed = call("GET", "/api/experiments?limit=200")
by_kind: dict[str, str] = {}
for row in listed if isinstance(listed, list) else []:
by_kind.setdefault(str(row.get("kind")), str(row.get("id")))
if not by_kind:
check(False, "归档列表可用于逐类验证", "列表为空(先跑一次回测/因子测试)")
for kind, aid in sorted(by_kind.items()):
code, html = get_text(f"{WEB}/experiments/{aid}")
check(code == 200, f"归档页 {kind}({aid})HTTP 200", str(code))
if code != 200:
continue
if kind == "backtest":
need = ["交易执行依据", "整体收益趋势"]
forbid: list[str] = []
elif kind == "factor_test":
need = ["因子测试配置", "IC"]
forbid = ["交易执行依据(撮合价", "整体收益趋势"]
elif kind == "selection":
need = ["选股条件", "选出"]
forbid = ["交易执行依据(撮合价"]
else:
need, forbid = ["归档"], []
missing = [k for k in need if k not in html]
check(not missing, f"归档页 {kind} 含该类型专属区块", f"缺失 {missing}" if missing else "全部命中")
leaked = [k for k in forbid if k in html]
check(
not leaked,
f"归档页 {kind} 不出现回测专属口径",
f"误出现 {leaked}(会让非回测归档看起来像跑过调仓成交)" if leaked else "未出现",
)
finally:
if created_id:
# 先删引用它的组合(若有),再删策略
stc, combos = call("GET", "/api/combos")
if isinstance(combos, list):
for cb in combos:
if created_id in (cb.get("strategy_ids") or []):
sd, _ = call("DELETE", f"/api/combos/{cb.get('id')}")
print(f"[cleanup] 删除自检组合 {cb.get('id')} → HTTP {sd}", flush=True)
st, _ = call("DELETE", f"/api/strategies/{created_id}")
print(f"[cleanup] 删除自检策略 {created_id} → HTTP {st}", flush=True)
print(f"\n结果:{_ok} 项通过 / {_bad} 项失败", flush=True)
return 1 if _bad else 0
if __name__ == "__main__":
raise SystemExit(main())