回测策略 selector 交替逻辑设计:防止信号死锁的代码模式

本文最后更新于 2026年8月13日 凌晨

我的回测引擎里有一个隐蔽的 bug,藏了三个月才被发现:RSI、布林带、肯特纳通道三个策略,每个都只产生了第一次买入信号,之后再也没卖过。 回测报告显示”100% 胜率、单笔交易”,看起来完美——实际上是信号死了。

根因是一行 copy-paste:卖出分支里把 in_buy 设成了 True,本该是 False。结果卖出后买入锁一直挂着,下一个买入信号永远被”已持仓”挡住,整条信号链断裂。这不是孤例,而是一类问题的典型——回测里的状态机一旦复位分支写错,就会”信号死锁”:两个方向互相等待对方释放,谁也动不了。

这篇文章拆解我在 AutoQuant 回测框架里怎么设计 selector 的交替逻辑,怎么用状态机模式根治这类死锁。所有代码来自真实提交,不是编的。

问题:什么叫信号死锁

回测策略的核心是一个循环:遍历每个交易日,根据指标判断买/卖,产出信号序列。一个”干净”的信号序列应该是严格交替的——买、卖、买、卖……因为你的持仓状态是二值的,买入后只有卖出才能回到空仓,反过来也一样。

但指标不会这么配合。RSI 可能在超买区连续停留 5 天,SMA 快慢线可能缠绕几十个来回。如果不加约束,你会拿到这样的信号序列:

1
buy, buy, buy, buy, buy, sell, buy, buy, sell, ...

连续的 buy 没有意义——你已经买过了,再买就是”加仓”,但回测引擎的记账逻辑(单仓、固定仓位)处理不了。这种信号喂给回测,要么报错,要么悄悄算错。

更恶心的是另一种情况:两个方向的信号互相锁死。 假设你的状态机有两个锁 in_buyin_sell,买入时置 in_buy=True,卖出时置 in_sell=True。如果卖出分支忘了把 in_buy 复位回 False,那么——

  • 卖出后,in_buy 仍是 True
  • 下一个买入信号来了,检查 if not in_buy,条件为假,不触发;
  • 于是再也买不进去,in_buy 永远是 True

这就是”信号死锁”:不是程序卡住,而是信号永远不再产生,回测在第一笔交易后就”冻住”了。表面看是高胜率,实际是样本量 1。

复现现场:一行 copy-paste 的杀伤力

这个 bug 的真实出处是 AutoQuant 的 scripts/backtest/strategies.py。我有一份原始的提交记录(commit 01c0838),它修复了三个策略的同款错误。下面是修复前的 RSI 策略 sell 分支:

1
2
3
4
5
6
# ❌ 修复前:sell 分支把 in_buy 设成了 True(copy-paste 自 buy 分支)
elif sell_cond:
if not in_sell:
signals.append(build_sell(i))
in_sell = True
in_buy = True # ← BUG:应该是 False

看出问题了吗?in_buy = True 是从 buy 分支复制过来忘改的。正确逻辑是:卖出后,买入锁应该释放(False),这样下一次买入信号才能触发。 但这里设成了 True,等于永远锁死买入。

这个错误同时出现在 RSI、布林带、肯特纳通道三个策略里,因为它们都是从 SMA 交叉策略 copy 出来的模板。copy-paste 是状态机 bug 的头号来源——每复制一次,就要改一次状态转移,漏改就是死锁。

修复版本:

1
2
3
4
5
6
# ✅ 修复后:sell 分支正确释放买入锁
elif sell_cond:
if not in_sell:
signals.append(build_sell(i))
in_sell = True
in_buy = False # ← 正确:卖出后释放买入锁

一个字符之差(TrueFalse),但回测结果天翻地覆。修复前,RSI 策略在某只 ETF 上一整年只产生 1 笔交易;修复后,正常交替,产生了 14 笔。

解法:把交替锁封装成基类方法

修掉这一行只是治标。真正的病灶是——8 个策略各自手写交替锁循环,逻辑重复了 8 遍,每遍都有写错的自由。 只要这个模式还散落在各处,下一个 copy-paste 还会埋下新的死锁。

治本的方法是把交替锁逻辑提取成一个不可写错的黑盒,所有策略只调用、不复写。我在 commit 47439fb 里做了这次重构,核心是给 BaseStrategy 加了一个 _detect_with_locks 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# strategies.py — BaseStrategy 基类
class BaseStrategy:
def _detect_with_locks(self, df, buy_cond, sell_cond, build_buy, build_sell):
"""封装交替锁去重循环:买入后必须卖出才能再买入,反之亦然。

交替锁避免连续同向信号;中性区(buy_cond 与 sell_cond 均为 False)
不改变锁状态,避免震荡区域重复信号。
"""
signals = []
in_buy = False
in_sell = False
for i in df.index:
if buy_cond.iloc[i]:
if not in_buy:
signals.append(build_buy(i))
in_buy = True
in_sell = False # 释放对向锁
elif sell_cond.iloc[i]:
if not in_sell:
signals.append(build_sell(i))
in_sell = True
in_buy = False # 释放对向锁
# 中性区不重置状态,保持交替锁
return self._dedupe_same_day(signals)

看清楚这套状态机的三条规则:

  1. 买入触发时:若未在买入态(not in_buy),产出买入信号并置 in_buy=True;同时无条件释放卖出锁 in_sell=False。因为你既然在买,就一定不在卖。
  2. 卖出触发时:镜像对称——释放买入锁。
  3. 中性区(两个条件都不满足):什么都不做。这条最关键,下一节单独讲。

关键在于,in_sell = Falsein_buy = False 这两行复位是写死在基类里的,子类碰不到。子类只负责提供 buy_cond/sell_cond(布尔条件)和 build_buy/build_sell(信号构造回调),状态转移逻辑完全由基类托管。只要基类的两行复位是对的,所有子类就都是对的。

子策略的写法简化了多少

重构前后,RSI 策略的 detect 方法从 30 多行手写循环,缩成了纯声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 重构后:RSI 策略只声明"什么时候买、什么时候卖、信号长什么样"
class RSI_Strategy(BaseStrategy):
name = "rsi"

def detect(self, df):
close = df["close"]
# ... 计算 rsi ...
return self._detect_with_locks(
df,
buy_cond=rsi < self.params["oversold"], # RSI<30 买入
sell_cond=rsi > self.params["overbought"], # RSI>70 卖出
build_buy=lambda i: StrategySignal(..., direction="buy", ...),
build_sell=lambda i: StrategySignal(..., direction="sell", ...),
)

8 个策略全部变成这个模板:算指标 → 声明两个条件 → 传两个回调。 信号去重、交替锁、状态复位,全部不可见、不可错。重构后净减少 14 行代码(重复逻辑消灭了),54 个测试全绿。

关键设计:中性区不重置状态

前面提到第三条规则”中性区不重置状态”,这是整套设计里最容易踩坑、也最容易被忽略的一点。展开讲讲。

假设 RSI 从超买区(>70)回落,在 50-70 之间徘徊了 10 天,然后再次冲上 75。这 10 天里,buy_cond(RSI<30)和 sell_cond(RSI>70)都是 False——这就是”中性区”。

中性区应该做什么?有两种选择,结果截然不同:

错误选择:中性区重置两个锁。 也就是把 else 分支写成 in_buy = in_sell = False。这样做的后果是——RSI 第二次冲上 75 时,因为 in_sell 已经被中性区重置成 False,会再产出一个卖出信号。但这不应该:你根本没有在两次超买之间买过,何来”再卖”?连续的卖出信号就这么产生了,交替锁形同虚设。

正确选择:中性区什么都不做。 锁状态保持原样。你卖出后 in_sell=True,中性区不碰它;RSI 再次超买时,if not in_sell 为假,不会重复卖出。只有当买入条件触发、把 in_sell 释放掉之后,下一次卖出才有资格发生。这才是真正的”交替”——用对向锁来决定本向信号能否触发。

这条规则的价值在震荡市尤其明显。窄幅震荡时指标会频繁穿越中性区,如果每次穿越都重置锁,信号会被刷成一片噪声。不重置,则信号保持稀疏、干净。

代码里只有一行注释保证这一点不会被人”优化”掉:

1
2
3
4
5
6
for i in df.index:
if buy_cond.iloc[i]:
...
elif sell_cond.iloc[i]:
...
# 中性区不重置状态,保持交替锁 ← 这行注释是设计契约,别删

我把这条规则连同前面的双锁释放,用单测钉死了:

1
2
3
4
5
6
7
8
9
10
11
# test_dedupe_locks.py — 中性区不重置锁
def test_locks_neutral_zone(self):
df = _make_df(["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04"],
[100, 101, 100, 101])
# Day1 买,Day2-3 中性,Day4 卖
buy_cond = pd.Series([True, False, False, False])
sell_cond = pd.Series([False, False, False, True])

result = s._detect_with_locks(df, buy_cond, sell_cond, build_buy, build_sell)
# 只有 buy 和 sell,中间两天不产出任何信号
assert [x.direction for x in result] == ["buy", "sell"]

用单测把状态机的不变量焊死

状态机的 bug 有个共同特征:运行时不报错,只是悄悄产出错误结果。 100% 胜率、1 笔交易——程序照样跑完,报告照样生成。这种 bug 只有靠测试主动去抓。

我给交替锁写了四类测试,覆盖状态机的所有转移路径(test_dedupe_locks.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class TestDetectWithLocks:
def test_locks_alternate(self):
"""严格交替:buy→sell→buy→sell"""
# buy/sell 条件交替为 True,断言输出严格交替

def test_locks_neutral_zone(self):
"""中性区不改变锁状态、不产出信号"""

def test_locks_start_with_sell(self):
"""第一个信号可以是 sell(不假设必须先 buy)"""

def test_locks_long_streak(self):
"""连续同向条件只产出第一个信号(锁住后续)"""
# buy_cond 连续 4 天 True,但只有第一天产出 buy 信号
assert [x.direction for x in result] == ["buy", "sell"]

这四个测试覆盖了状态机的全部转移:交替、中性、起始方向、长连。 只要这些不变量不破,死锁类的 bug 就无所遁形。

特别值得强调 test_locks_long_streak——它直接对应死锁的反面。连续买入条件只产出第一个信号,是因为 in_buy 锁住了后续;而能产出后续卖出信号,是因为卖出分支正确释放了 in_buy如果有人把 in_buy = False 改回 True,这个测试立刻红。 这就是用测试守住关键不变量的意义。

番外:迟滞保护防止 selector 级”横跳”

交替锁解决的是单策略内部的信号死锁。但在策略 selector(选优器)层面,还有一个对称的”死锁”——策略之间来回横跳。

strategy_selector.py 会给每只标的选出”最优买入策略”和”最优卖出策略”。问题在于:评分是浮动的,今天 VWAP 得分 60、Turtle 得分 58,明天数据更新后可能反过来。如果每次都纯取 max,selector 就会在两个策略间反复切换——今天用 VWAP,明天用 Turtle,后天又回 VWAP。这不是死锁,但性质类似:两个选项互相等待对方让位,系统无法稳定在一个决策上。

解法是迟滞(hysteresis),和硬件里的施密特触发器一个原理——加一个切换阈值,得分差距不够大就不切:

1
2
3
4
5
6
7
8
9
10
11
# strategy_selector.py — 迟滞保护
STRATEGY_HYSTERESIS = float(os.environ.get("STRATEGY_HYSTERESIS", "5"))

if hysteresis > 0 and prev_best_buy:
prev_buy_score = lookup_score(prev_best_buy)
if abs_best_buy[0] != prev_best_buy:
if abs_best_buy[1] - prev_buy_score < hysteresis:
# 得分差距不足 5 分,不切换,保持原策略
best_buy = (prev_best_buy, prev_buy_score)
else:
buy_switched = True

这套迟滞逻辑同样有单测覆盖(test_hysteresis.py,14 个用例):差距小于阈值保持原策略、等于阈值允许切换、hysteresis=0 退化为纯取 max、边界场景全覆盖。

交替锁和迟滞保护是同一个设计哲学的两面:交替锁防止单策略内连续同向信号(内部死锁),迟滞防止多策略间频繁切换(外部横跳)。两者都通过”加一个状态约束,让转移不那么自由”来换取稳定性。自由度降下来了,可预测性就上去了。

避坑清单

总结这套设计里最容易踩的四个坑,每一个都用血泪换的:

坑一:复位分支漏写或写错。 最经典。buy 分支要对向锁复位 in_sell=False,sell 分支要对向锁复位 in_buy=False。copy-paste 时极易漏改。解法:把复位逻辑关进基类黑盒,子类碰不到。

坑二:中性区重置锁。 看起来”干净”,实际破坏交替性。震荡市里会刷出一片重复信号。解法:中性区显式什么都不做,并用注释和单测标明这是设计契约。

坑三:假设首信号一定是 buy。 有的策略(比如唐奇安通道突破)第一信号可能是卖出。如果状态机初始化时假设 in_buy=False, in_sell=False 且不处理”先卖后买”,就会漏信号。解法:两个锁都从 False 起步,谁先触发都行——上面的状态机天然满足这点。

坑四:用单测试跑指标、不测状态转移。 很多回测测试只验证”RSI 算得对不对”,不验证”信号序列是否严格交替”。状态机 bug 恰恰藏在转移里,指标算对了信号照样能死锁。解法:独立测 _detect_with_locks,不依赖具体策略和行情数据,直接喂布尔条件。

这套模式的边界

交替锁 + 基类封装,解决的是”严格交替”场景的信号死锁。但它有适用边界,不是银弹。

只适合单仓位的二值状态。 你的回测如果支持加仓、减仓、多空双向、不同仓位档位,那持仓状态就不是 buy/sell 二值,交替锁会过度约束。这时状态机要扩展成”仓位档位”枚举,锁的逻辑相应复杂。

严格交替可能漏掉合理信号。 现实中”买入后再买入”(加仓)有时是合理的,比如金字塔加仓法。交替锁会一刀切掉这类信号。如果你的策略需要加仓,得在交替锁之外另开一条加仓逻辑,和主锁解耦。

基类黑盒牺牲灵活性换安全。 把逻辑关进基类后,个别策略想要非标准行为(比如”允许连续两个 buy”)就没法用 _detect_with_locks 了,得自己写循环——而这正是死锁重新抬头的地方。权衡是:用黑盒保护 90% 的标准场景,只对 10% 的特例开窗,并对每个特例单独测试。

把重复的、易错的状态逻辑收敛到一个经过充分测试的地方,让业务代码只描述”什么时候买、什么时候卖”——这个朴素的原则,比任何花哨的框架都更能防住死锁。信号会交替,但你的代码不该在同一个坑里交替摔倒。


参考文献

  1. AutoQuant 回测框架源码 scripts/backtest/strategies.py_detect_with_locks 基类实现
  2. AutoQuant scripts/backtest/strategy_selector.py — 迟滞保护 STRATEGY_HYSTERESIS 逻辑
  3. AutoQuant scripts/backtest/test_dedupe_locks.py — 交替锁状态机单测(4 类不变量)
  4. AutoQuant scripts/backtest/test_hysteresis.py — selector 迟滞单测(14 用例)
  5. 相关前置文章:《回测策略的 copy-paste bug:sell 分支 in_buy=True 让信号死锁》— 同一系列的死锁 bug 根因分析

回测策略 selector 交替逻辑设计:防止信号死锁的代码模式
https://normdist.com/2026/08/12/ND-20260812-003-selector-alternation-deadlock/
作者
小瑞
发布于
2026年8月12日
许可协议