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/ 是临时验证证据,不入库(文件留在磁盘)。
This commit is contained in:
Simon
2026-10-01 16:38:54 +08:00
parent f13b34c59e
commit 36fe018075
6 changed files with 1317 additions and 62 deletions
+393 -36
View File
@@ -8,7 +8,7 @@
- `GET /api/strategies/{id}/describe`、`POST /api/strategies/describe`:说明 + 计算公式;
- 回测结果里 `symbol_curves/positions/trades` 必须带 `name`(前端「代码必须配名称」依赖);
- 选股结果 `candidates[].name` 与 `config_snapshot`(选股 → 回测直通依赖它取回当时的规则);
- 页面 SSR:/strategies、/backtest、/experiments 必须 200 且含关键区块;
- 页面 SSR:/strategies、/backtest、/fields、/experiments 必须 200 且含关键区块;
- **归档链路**:`GET /api/experiments` 的 `X-Total-Count` 与 kind/q 过滤、归档详情含
`data_version`/`job_id`、`/experiments/{id}` 归档页 SSR 能渲染、`DELETE` 语义正确。
@@ -70,6 +70,17 @@ def call(method: str, path: str, body: object | None = None, timeout: float = 60
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:
@@ -78,24 +89,25 @@ def get_text(url: str, timeout: float = 120.0) -> tuple[int, str]:
return e.code, e.read().decode("utf-8", "ignore")[:200]
# 选股策略(2026-09 重构后只含「怎么选」;资金/持仓/调仓/费率/区间移到回测组合与公共配置)
STRATEGY = {
"name": f"契约自检-高股息-{int(time.time())}",
"description": "", # 故意留空:验证后端自动补全
"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,
},
}
# 回测组合(引用上面的选股策略 + 回测参数)—— 用于 [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"],
}
@@ -130,26 +142,32 @@ def main() -> int:
"说明为空时被自动补全(策略必须有说明)",
(saved.get("description") or "")[:80],
)
check(
saved.get("selection_interval_months") == 6 and saved.get("rebalance_interval_months") == 6,
"m/y 字段持久化(策略 JSON blob,无需迁移)",
f"m={saved.get('selection_interval_months')} y={saved.get('rebalance_interval_months')}",
)
# 选股策略不应持久化任何回测执行参数(重构核心约束)
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["selection"] = {**STRATEGY["selection"], "top_n": 15, "hold_top_x": 10}
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(
(after.get("selection") or {}).get("top_n") == 15,
"参数已更新(n=15)",
str((after.get("selection") or {}).get("top_n")),
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 未被刷新(避免「改一下就排最前」)",
@@ -176,10 +194,15 @@ def main() -> int:
check(bool(summary), "summary 非空(一句话说明)", summary[:90])
check("dividend_yield" in formula, "公式含因子名", "dividend_yield")
check("dv_ratio" in formula, "公式含过滤条件字段", "dv_ratio")
check("30" in formula, "公式含条件阈值", "30")
check(
any(k in formula for k in ("佣金", "印花", "滑点", "commission")),
"公式含成本项",
"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 = {
@@ -206,20 +229,339 @@ def main() -> int:
st, _ = call("POST", "/api/strategies/describe", bad)
check(st == 422, "allow_substitute 与 defer_buy 同真被拒(前端用三态单选规避)", f"HTTP {st}")
# ---------- 5. 展开 + 回测 ----------
print("[5] 展开为 Spec" + ("" if args.skip_job else " + 真实回测 Job"), flush=True)
st, spec = call(
# ---------- 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 == 200 and isinstance(spec, dict), "POST /strategies/{id}/expand", f"HTTP {st}")
if isinstance(spec, dict):
check(spec.get("selection", {}).get("top_n") == 15, "展开沿用了更新后的参数", str(spec.get("selection")))
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))
if not args.skip_job and isinstance(spec, dict):
st, job = call("POST", "/api/jobs", spec)
check(st == 200, "POST /api/jobs(策略 → 回测)", f"HTTP {st}")
# 组合库(列表 / 详情 / 原地更新)—— 前端「已保存的回测组合」卡片依赖这些读路径
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()
@@ -342,8 +684,16 @@ def main() -> int:
# ---------- 7. 页面 SSR ----------
print("[7] 页面可访问性与关键区块", flush=True)
pages = [
("/strategies", ["策略库", "计算公式", "一句话说明"]),
("/backtest", ["选股回测", "保存为策略", "计算公式"]),
("/strategies", ["选股策略库", "新建选股策略", "股票池"]),
# 注意:只验 SSR 就能看到的静态文案。「直接运行/载入到表单」是组合列表
# 非空时客户端渲染出来的按钮,SSR 阶段列表还在加载,断言它们会假失败。
("/backtest", ["回测组合", "选择选股策略", "持仓数 N", "已保存的回测组合"]),
("/settings", ["公共配置", "交易成本与行情口径", "全局唯一"]),
# 字段库:说明卡是 SSR 静态内容,可断言;字段表格由客户端拉取后渲染
("/fields", ["字段库", "打分因子 与 过滤条件 是什么关系", "新增字段"]),
# 因子研究:说明「参数可改(新建参数化因子)、口径按代码收敛」必须是 SSR 文案 ——
# 参数化之前这里写的是「不可修改」,那句话现在已经不成立(不能留假话)
("/factors", ["因子目录", "投影", "参数", "新建参数化因子"]),
("/experiments", ["实验对比", "参数"]),
]
if exp_id:
@@ -394,6 +744,13 @@ def main() -> int:
)
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)