From 0bc5ff1831a8025a2bfc06cd7d2dee1e528bb3ff Mon Sep 17 00:00:00 2001 From: wshy Date: Wed, 8 Jul 2026 12:34:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(strategy):=20=E5=8A=A0=E8=BD=BD=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E5=BD=92=E4=B8=80=E5=8C=96=20META=20params,=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=9D=9E=E6=A0=87=E5=87=86=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=20/api/strategies=20500=20(fixes=20#68)=20(#?= =?UTF-8?q?72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 的回归断言. --- backend/app/strategy/engine.py | 64 ++++++++ .../tests/test_strategy_param_normalize.py | 147 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 backend/tests/test_strategy_param_normalize.py diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 1f27e62..ff3330c 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -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) diff --git a/backend/tests/test_strategy_param_normalize.py b/backend/tests/test_strategy_param_normalize.py new file mode 100644 index 0000000..92a202e --- /dev/null +++ b/backend/tests/test_strategy_param_normalize.py @@ -0,0 +1,147 @@ +"""策略 params 归一化测试 (issue #68 回归). + +回归点: custom/AI 策略的 META["params"] 若是 dict / list[str] 等非标准格式, +原实现会在 _strategy_detail() 的 {p["id"]: p["default"] for p in params} 处抛 +TypeError, 导致整个 /api/strategies 列表 500. 加载边界归一化后保证 params 永远 +是标准 list[dict], 下游推导式天然安全. +""" +from __future__ import annotations + +from app.api.strategy import _strategy_detail +from app.strategy.engine import _normalize_param_defs, StrategyDef + + +# ── 归一化各格式分支 ────────────────────────────────────────────────── + + +def test_none_returns_empty_list(): + assert _normalize_param_defs(None) == [] + + +def test_standard_list_dict_keeps_and_fills_defaults(): + """标准 list[dict]: 保持结构, 补齐缺失的 label/type/default.""" + params = [ + {"id": "lookback", "label": "回看天数", "type": "int", "default": 7, + "min": 3, "max": 30, "step": 1}, + {"id": "threshold", "type": "float", "default": 0.1}, # 缺 label + {"id": "flag", "default": True}, # 缺 label/type + ] + result = _normalize_param_defs(params) + assert len(result) == 3 + # 完整项原样保留 + assert result[0]["id"] == "lookback" + assert result[0]["min"] == 3 + # 缺字段被补齐 + assert result[1]["label"] == "threshold" + assert result[2]["label"] == "flag" + assert result[2]["type"] == "float" # 默认 type + + +def test_dict_simple_values(): + """dict 格式 - 纯值: {"lookback": 20} → [{id, default}].""" + params = {"lookback": 20, "threshold": 0.15} + result = _normalize_param_defs(params) + by_id = {p["id"]: p for p in result} + assert by_id["lookback"]["default"] == 20 + assert by_id["threshold"]["default"] == 0.15 + # 补齐默认字段 + assert by_id["lookback"]["label"] == "lookback" + assert by_id["lookback"]["type"] == "float" + + +def test_dict_nested_definitions(): + """dict 格式 - 嵌套定义: {"k": {"default": 1, "type": "int"}} → 合并.""" + params = {"lookback": {"default": 20, "type": "int", "min": 3}} + result = _normalize_param_defs(params) + assert len(result) == 1 + assert result[0]["id"] == "lookback" + assert result[0]["default"] == 20 + assert result[0]["type"] == "int" + assert result[0]["min"] == 3 + + +def test_list_of_strings(): + """list[str]: ["k1", "k2"] → [{id: "k1"}, {id: "k2"}], default=None.""" + params = ["lookback", "threshold"] + result = _normalize_param_defs(params) + assert len(result) == 2 + assert result[0]["id"] == "lookback" + assert result[0]["default"] is None + assert result[1]["id"] == "threshold" + + +def test_invalid_type_degrades_to_empty(): + """整体类型不可识别 (int/str/bool) → 降级为空 list, 不抛异常.""" + assert _normalize_param_defs(42) == [] + assert _normalize_param_defs("lookback") == [] + assert _normalize_param_defs(True) == [] + + +def test_mixed_dirty_items_drop_unrecognized(): + """混合脏项: dict 项保留, 不可识别项 (int/None) 丢弃.""" + params = [ + {"id": "valid", "default": 10}, + 42, # 丢弃 + None, # 丢弃 + "str_id", # 保留作 id + ] + result = _normalize_param_defs(params) + ids = [p["id"] for p in result] + assert ids == ["valid", "str_id"] + + +def test_dict_item_missing_id_dropped(): + """list[dict] 里某项缺 id → 该项丢弃, 其他不受影响.""" + params = [ + {"id": "ok", "default": 1}, + {"label": "no id here"}, # 无 id, 丢弃 + {"id": "ok2"}, + ] + result = _normalize_param_defs(params) + ids = [p["id"] for p in result] + assert ids == ["ok", "ok2"] + + +# ── issue #68 核心: _strategy_detail 不再 500 ────────────────────── + + +def _make_strategy_with_params(params) -> StrategyDef: + """构造 META["params"] = params 的策略 (模拟非标准格式的 custom/AI 文件).""" + return StrategyDef( + meta={"id": "test_strat", "name": "测试", "params": params}, + basic_filter={"enabled": True}, + entry_signals=[], exit_signals=[], + stop_loss=None, trailing_stop=None, + trailing_take_profit_activate=None, trailing_take_profit_drawdown=None, + max_hold_days=None, alerts=[], + filter_fn=None, filter_history_fn=None, + lookback_days=60, source="custom", + ) + + +def test_strategy_detail_survives_dict_params(): + """issue #68 核心: params 是 dict 时 _strategy_detail 不再抛 TypeError.""" + # 注: 实际加载会经 _load_file 归一化; 这里模拟"已归一化后"的状态, + # 直接证明归一化产物能让 _strategy_detail 安全运行. + raw_params = {"lookback": 20, "threshold": 0.15} + normalized = _normalize_param_defs(raw_params) + s = _make_strategy_with_params(normalized) + detail = _strategy_detail(s) # 不应抛异常 + assert detail["params_defaults"] == {"lookback": 20, "threshold": 0.15} + + +def test_strategy_detail_survives_list_str_params(): + """issue #68: params 是 list[str] 时也不再 500.""" + normalized = _normalize_param_defs(["lookback", "threshold"]) + s = _make_strategy_with_params(normalized) + detail = _strategy_detail(s) + assert detail["params_defaults"] == {"lookback": None, "threshold": None} + + +def test_strategy_detail_survives_empty_params(): + """整体非法格式降级为空 list 时, _strategy_detail 正常返回空 params_defaults.""" + normalized = _normalize_param_defs(42) # 降级为 [] + s = _make_strategy_with_params(normalized) + detail = _strategy_detail(s) + assert detail["params_defaults"] == {} + assert detail["params"] == []