fix(backtest): 策略参数加跨语义约束,杜绝寻优选出快慢倒挂组合(issue #39)

寻优器网格探索不区分语义:ma_cross 的 fast∈[5..60] × slow∈[10..250]
笛卡尔积包含 {"fast":30,"slow":20} 这类倒挂组合,倒挂的双均线交叉
本质是反向策略,回测成绩可能反而突出,从而被选为"最优参数"展示
(即 issue #39 截图中 {"fast":30,"slow":20} 的来源)。presets.py 的
注释"快线<慢线才有意义;去重无效组合"早已写下意图但从未实现。

根因是参数校验只有单参数 min/max,缺跨参数语义约束:

- ParametrizedStrategy 新增 param_constraints 类属性 [(a,b), ...]
  表示要求 a<b,在 __init__ 解析后统一校验,报错带中文标签;
  语义约束不受 skip_bounds 影响(寻优跳过的只是数值边界)
- 7 个策略声明约束:ma_cross/ema_cross(fast<slow)、macd
  (short<long)、triple_ma(short<mid<long)、rsi_reversal/cci/
  wr_reversal(oversold<overbought)
- 寻优器 build 阶段的 ValueError 降为 info 级跳过(无堆栈噪音),
  回测异常仍走 warning;预设网格 36 组合 → 有效 25 个
- 新增 10 个回归测试(旧代码全失败、新代码全通过)
This commit is contained in:
GitHub
2026-08-19 18:55:48 +08:00
parent cc17ab650d
commit 3180ab3002
5 changed files with 94 additions and 5 deletions
+43
View File
@@ -111,6 +111,49 @@ def test_strategy_rejects_out_of_range_param():
get_registry().get("ma_cross").build({"fast": 999})
def test_strategy_rejects_inverted_period_params():
"""快/慢周期倒挂(fast≥slow)应抛 ValueErrorissue #39)。"""
from easy_tdx.backtest.strategies import get_registry
with pytest.raises(ValueError, match="必须小于"):
get_registry().get("ma_cross").build({"fast": 30, "slow": 20})
# 相等同样无效:两线重合,交叉永不触发
with pytest.raises(ValueError, match="必须小于"):
get_registry().get("ma_cross").build({"fast": 20, "slow": 20})
@pytest.mark.parametrize(
"name,params",
[
("ma_cross", {"fast": 30, "slow": 20}),
("ema_cross", {"fast": 30, "slow": 20}),
("macd", {"short": 30, "long": 20}),
("triple_ma", {"short": 30, "mid": 20}),
("triple_ma", {"short": 5, "mid": 80, "long": 60}),
("rsi_reversal", {"oversold": 60, "overbought": 50}),
("cci", {"oversold": 50, "overbought": -50}),
("wr_reversal", {"oversold": -50, "overbought": -60}),
],
)
def test_param_constraints_enforced_despite_skip_bounds(name, params):
"""跨参数语义约束不受 skip_bounds 影响(寻优防倒挂组合,issue #39)。"""
from easy_tdx.backtest.strategies import get_registry
with pytest.raises(ValueError, match="必须小于"):
get_registry().get(name).build(params, skip_bounds=True)
def test_skip_bounds_still_allows_out_of_range_values():
"""skip_bounds 仍应放行超范围取值(只是不放过语义倒挂)。"""
from easy_tdx.backtest.strategies import get_registry
inst = get_registry().get("ma_cross").build(
{"fast": 100, "slow": 200}, skip_bounds=True
)
assert inst.p["fast"] == 100
assert inst.p["slow"] == 200
def test_strategy_rejects_unknown_name():
"""未知策略名应抛 KeyError。"""
from easy_tdx.backtest.strategies import get_registry