回测策略 selector 交替逻辑设计:防止信号死锁的代码模式
本文最后更新于 2026年8月13日 凌晨
我的回测引擎里有一个隐蔽的 bug,藏了三个月才被发现:RSI、布林带、肯特纳通道三个策略,每个都只产生了第一次买入信号,之后再也没卖过。 回测报告显示”100% 胜率、单笔交易”,看起来完美——实际上是信号死了。
根因是一行 copy-paste:卖出分支里把 in_buy 设成了 True,本该是 False。结果卖出后买入锁一直挂着,下一个买入信号永远被”已持仓”挡住,整条信号链断裂。这不是孤例,而是一类问题的典型——回测里的状态机一旦复位分支写错,就会”信号死锁”:两个方向互相等待对方释放,谁也动不了。
这篇文章拆解我在 AutoQuant 回测框架里怎么设计 selector 的交替逻辑,怎么用状态机模式根治这类死锁。所有代码来自真实提交,不是编的。
问题:什么叫信号死锁
回测策略的核心是一个循环:遍历每个交易日,根据指标判断买/卖,产出信号序列。一个”干净”的信号序列应该是严格交替的——买、卖、买、卖……因为你的持仓状态是二值的,买入后只有卖出才能回到空仓,反过来也一样。
但指标不会这么配合。RSI 可能在超买区连续停留 5 天,SMA 快慢线可能缠绕几十个来回。如果不加约束,你会拿到这样的信号序列:
1 | |
连续的 buy 没有意义——你已经买过了,再买就是”加仓”,但回测引擎的记账逻辑(单仓、固定仓位)处理不了。这种信号喂给回测,要么报错,要么悄悄算错。
更恶心的是另一种情况:两个方向的信号互相锁死。 假设你的状态机有两个锁 in_buy 和 in_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 | |
看出问题了吗?in_buy = True 是从 buy 分支复制过来忘改的。正确逻辑是:卖出后,买入锁应该释放(False),这样下一次买入信号才能触发。 但这里设成了 True,等于永远锁死买入。
这个错误同时出现在 RSI、布林带、肯特纳通道三个策略里,因为它们都是从 SMA 交叉策略 copy 出来的模板。copy-paste 是状态机 bug 的头号来源——每复制一次,就要改一次状态转移,漏改就是死锁。
修复版本:
1 | |
一个字符之差(True → False),但回测结果天翻地覆。修复前,RSI 策略在某只 ETF 上一整年只产生 1 笔交易;修复后,正常交替,产生了 14 笔。
解法:把交替锁封装成基类方法
修掉这一行只是治标。真正的病灶是——8 个策略各自手写交替锁循环,逻辑重复了 8 遍,每遍都有写错的自由。 只要这个模式还散落在各处,下一个 copy-paste 还会埋下新的死锁。
治本的方法是把交替锁逻辑提取成一个不可写错的黑盒,所有策略只调用、不复写。我在 commit 47439fb 里做了这次重构,核心是给 BaseStrategy 加了一个 _detect_with_locks 方法:
1 | |
看清楚这套状态机的三条规则:
- 买入触发时:若未在买入态(
not in_buy),产出买入信号并置in_buy=True;同时无条件释放卖出锁in_sell=False。因为你既然在买,就一定不在卖。 - 卖出触发时:镜像对称——释放买入锁。
- 中性区(两个条件都不满足):什么都不做。这条最关键,下一节单独讲。
关键在于,in_sell = False 和 in_buy = False 这两行复位是写死在基类里的,子类碰不到。子类只负责提供 buy_cond/sell_cond(布尔条件)和 build_buy/build_sell(信号构造回调),状态转移逻辑完全由基类托管。只要基类的两行复位是对的,所有子类就都是对的。
子策略的写法简化了多少
重构前后,RSI 策略的 detect 方法从 30 多行手写循环,缩成了纯声明:
1 | |
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 | |
我把这条规则连同前面的双锁释放,用单测钉死了:
1 | |
用单测把状态机的不变量焊死
状态机的 bug 有个共同特征:运行时不报错,只是悄悄产出错误结果。 100% 胜率、1 笔交易——程序照样跑完,报告照样生成。这种 bug 只有靠测试主动去抓。
我给交替锁写了四类测试,覆盖状态机的所有转移路径(test_dedupe_locks.py):
1 | |
这四个测试覆盖了状态机的全部转移:交替、中性、起始方向、长连。 只要这些不变量不破,死锁类的 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 | |
这套迟滞逻辑同样有单测覆盖(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% 的特例开窗,并对每个特例单独测试。
把重复的、易错的状态逻辑收敛到一个经过充分测试的地方,让业务代码只描述”什么时候买、什么时候卖”——这个朴素的原则,比任何花哨的框架都更能防住死锁。信号会交替,但你的代码不该在同一个坑里交替摔倒。
参考文献
- AutoQuant 回测框架源码
scripts/backtest/strategies.py—_detect_with_locks基类实现 - AutoQuant
scripts/backtest/strategy_selector.py— 迟滞保护STRATEGY_HYSTERESIS逻辑 - AutoQuant
scripts/backtest/test_dedupe_locks.py— 交替锁状态机单测(4 类不变量) - AutoQuant
scripts/backtest/test_hysteresis.py— selector 迟滞单测(14 用例) - 相关前置文章:《回测策略的 copy-paste bug:sell 分支 in_buy=True 让信号死锁》— 同一系列的死锁 bug 根因分析