fix(strategy): 加载边界归一化 META params, 修复非标准格式导致 /api/strategies 500 (fixes #68) (#72)

custom/AI 策略的 META["params"] 可能是 dict / list[str] 等非标准格式 (LLM 偶发
漂移 / 用户手改). 加载时 _load_file 只 setdefault("params", []) 不校验格式,
坏策略照样进内存, 在 _strategy_detail() 的 {p["id"]: p["default"] for p in params}
处抛 TypeError, 一个坏策略拖垮整个 /api/strategies 列表 500. 回测链路
backtest/strategy.py:589 的 _normalize_params 同样会崩.

在 _load_file 加载边界新增 _normalize_param_defs(), 把任意格式统一成标准
list[dict]: dict 按 key 作 id 转换, list[str] 每项作 id, 不可识别项丢弃并 warning,
整体异常降级为空 list. 三类来源 (builtin/custom/ai) 都走 _load_file, 单点覆盖.
降级而非崩溃: 策略仍可见可用, 只是设置面板无参数滑块.

新增 11 个测试覆盖归一化各分支 + _strategy_detail 不再 500 的回归断言.
This commit is contained in:
wshy
2026-07-08 12:34:08 +08:00
committed by GitHub
parent e5a94c42d5
commit 0bc5ff1831
2 changed files with 211 additions and 0 deletions
+64
View File
@@ -35,6 +35,64 @@ DEFAULT_BASIC_FILTER: dict = {
}
def _normalize_param_defs(params: Any) -> list[dict]:
"""把 META["params"] 归一化为标准 list[dict] (每项含 id/label/type/default).
支持的输入格式:
- list[dict] (标准): 保持, 补齐缺失的 id/label/type/default 字段
- dict ({"lookback": 20} 或 {"lookback": {"default": 20, "type": "int"}}):
按 key 作参数 id 转换
- list[str] (["lookback", "threshold"]): 每项作 id, default=None
- 其他类型 / 不可识别项: 丢弃并 warning 记录; 整体异常则返回空 list (降级而非崩溃)
保证下游 {p["id"]: p["default"] for p in params} 永远不会因格式问题抛 TypeError.
"""
if params is None:
return []
# dict 格式: {"lookback": 20} 或 {"lookback": {"default": 20, "type": "int"}}
if isinstance(params, dict):
items: list[dict] = []
for key, val in params.items():
if not isinstance(key, str) or not key:
continue
if isinstance(val, dict):
item = {"id": key, **val}
else:
item = {"id": key, "default": val}
items.append(item)
return [_normalize_param_item(item) for item in items]
# 期望是 list/tuple, 其他类型直接降级
if not isinstance(params, (list, tuple)):
logger.warning("strategy params 非标准格式 (%s), 已降级为空 list", type(params).__name__)
return []
result: list[dict] = []
for i, p in enumerate(params):
if isinstance(p, str):
result.append({"id": p, "default": None})
elif isinstance(p, dict):
item = _normalize_param_item(p)
if item: # 缺 id 等异常项 _normalize_param_item 返回空 dict, 丢弃
result.append(item)
else:
logger.warning("strategy params[%d] 不可识别 (%s), 已丢弃", i, type(p).__name__)
return result
def _normalize_param_item(item: dict) -> dict:
"""补齐单个参数定义的默认字段, 保证 id/label/type/default 都存在."""
norm = dict(item)
if "id" not in norm or not norm["id"]:
logger.warning("strategy param 定义缺少 id, 已丢弃: %s", item)
return {}
norm.setdefault("label", str(norm["id"]))
norm.setdefault("type", "float")
norm.setdefault("default", None)
return norm
@dataclass
class StrategyDef:
"""加载后的策略定义(只读数据 + filter 函数引用)"""
@@ -130,6 +188,12 @@ class StrategyEngine:
meta.setdefault("descending", True)
meta.setdefault("limit", 100)
# 归一化 params 为标准 list[dict]: custom/AI 策略的 META["params"] 可能是
# dict / list[str] 等非标准格式 (LLM 偶发漂移 / 用户手改), 不归一化的话会在
# _strategy_detail() 的 {p["id"]: p["default"] for p in params} 处抛 TypeError,
# 导致整个 /api/strategies 列表 500. 降级为空 list 而非崩溃, 策略仍可见可用.
meta["params"] = _normalize_param_defs(meta.get("params"))
# 合并默认基础过滤
bf = {**DEFAULT_BASIC_FILTER}
strat_bf = getattr(mod, "BASIC_FILTER", None)