本文最后更新于 2026年8月11日 凌晨
回测跑出来一条只涨不跌的净值曲线,胜率 100%,最大回撤 0%。
这不是圣杯,这是 bug。
策略里买入后该卖出的信号一次都没触发,仓位从开仓那天起就锁死在账户里。净值曲线”看起来”只涨不跌,只是因为它从来没结算过亏损。最讽刺的是,这个 bug 的源头只是一个 copy-paste:把 buy 分支的代码复制到 sell 分支,忘了把一个布尔标志从 True 改成 False。
copy-paste 为什么这么危险 人在写第二段相似代码时,大脑会切到”自动驾驶”模式。第一段(buy 分支)是你逐字敲出来的,每行都过脑子。第二段(sell 分支)你只是复制、粘贴、改几个词。问题在于,你改了显眼的变量名,却漏了那个不起眼的状态标志。
心理学上叫”无意视盲”(inattentional blindness)——注意力聚焦在”改对函数名和方向”上,就看不见那个藏在第五行的 in_buy = True 应该改成 False。
代码结构上更危险。copy-paste 出来的两段代码高度相似但语义相反,这种”对称结构”会让后续阅读者也产生惯性,扫一眼觉得”差不多对”就跳过了。
状态机不变量被悄悄打破 一个典型的回测策略本质上是个状态机:两个状态(空仓 flat、持仓 in_position),两个事件(买入信号、卖出信号),四条转移规则。
不变量只有一条:任何时候,持仓标志 in_position 必须与实际的持仓方向一致 ——有仓位时为 True,没仓位时为 False,不能停在中间态。
1 2 assert (buy_count - sell_count) == (1 if in_position else 0 )
buy 分支正常工作时,状态流转是 flat → in_position。但如果 sell 分支因为 copy-paste 把守卫条件也复制了过去——本该 if in_position 却写成了 if not in_position——状态机就卡住了。持仓后遇到 SELL 信号,not in_position 为假,整个分支被跳过,卖出操作永远不执行,仓位从开仓那天起就锁死。
死锁的隐蔽性在于它不报错。没有异常,没有崩溃,策略安静地”持仓不动”。如果你只看净值曲线而不看交易明细,根本发现不了。
最小可复现的 Python 示例 把问题缩到最小:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class SimpleStrategy : def __init__ (self ): self .in_position = False self .trades = [] def on_signal (self, signal, price ): if signal == "BUY" and not self .in_position: self .in_position = True self .trades.append(("BUY" , price)) elif signal == "SELL" and not self .in_position: self .in_position = False self .trades.append(("SELL" , price))
跑一下回测,模拟一组 BUY-SELL-BUY-SELL 信号:
1 2 3 4 5 6 7 8 9 strat = SimpleStrategy() signals = [("BUY" , 100 ), ("SELL" , 110 ), ("BUY" , 105 ), ("SELL" , 108 )]for signal, price in signals: strat.on_signal(signal, price)print (f"交易笔数: {len (strat.trades)} " )print (f"最终持仓状态: {strat.in_position} " )print (f"交易记录: {strat.trades} " )
输出:
1 2 3 交易笔数: 1 最终持仓状态: True 交易记录: [('BUY', 100)]
四条信号只执行了第一条买入。SELL 信号在持仓时遇到守卫 not self.in_position,结果为假,整个 elif 分支被跳过——卖出操作根本没执行,状态也没翻转。整条策略链从第二个信号起就完全失效,却没有任何报错。
三道防线 第一道:状态断言 在每个状态转移操作后加一行断言,把不变量焊死在代码里:
1 2 3 4 5 6 7 8 9 10 11 12 13 def _check_invariant (self ): """状态机不变量:交易计数与持仓状态一致""" buys = sum (1 for s, _ in self .trades if s == "BUY" ) sells = sum (1 for s, _ in self .trades if s == "SELL" ) expected_position = (buys - sells) == 1 assert self .in_position == expected_position, \ f"状态冲突: in_position={self.in_position} , 但交易记录 implies {expected_position} " def on_signal (self, signal, price ): if signal == "BUY" and not self .in_position: self .in_position = True self .trades.append(("BUY" , price)) self ._check_invariant()
copy-paste 把 in_position = True 带进 sell 分支后,这行断言会立刻在测试阶段炸出来,而不是让 bug 潜伏到实盘。
第二道:对称性检查 buy 和 sell 是镜像操作,用对称结构写,让两个分支的差异一目了然:
1 2 3 4 5 6 7 8 9 10 11 12 13 def _enter_position (self, price ): self .in_position = True self .trades.append(("BUY" , price))def _exit_position (self, price ): self .in_position = False self .trades.append(("SELL" , price))def on_signal (self, signal, price ): if signal == "BUY" and not self .in_position: self ._enter_position(price) elif signal == "SELL" and self .in_position: self ._exit_position(price)
把状态转移收敛到两个方法里,buy 和 sell 不再各写一份。copy-paste 的机会从”复制整个分支”降到了零。即使要改状态逻辑,也只需要改一个地方。
第三道:属性测试 属性测试(property-based testing)不预设具体用例,而是让框架自动生成大量随机信号序列,验证策略始终满足不变量。用 hypothesis 库:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 from hypothesis import given, strategies as st, settings@given(st.lists( st.tuples(st.sampled_from(["BUY" , "SELL" ] ), st.floats(1 , 1000 ) ), max_size=50 ) )@settings(max_examples=200 ) def test_strategy_always_consistent (signals ): """无论什么信号序列,策略状态机永远满足不变量""" strat = SimpleStrategy() for signal, price in signals: strat.on_signal(signal, price) expected = len ([s for s in strat.trades if s[0 ] == "BUY" ]) == \ len ([s for s in strat.trades if s[0 ] == "SELL" ]) + (1 if strat.in_position else 0 ) assert expected, f"状态不一致: 持仓={strat.in_position} , 交易={strat.trades} "
200 组随机信号序列里,只要有任何一组让 buy 次数和 sell 次数与持仓状态对不上,测试就挂。copy-paste 遗留的 bug 在这种暴力扫描下无处藏身。
交易明细才是真相 净值曲线会骗人,交易明细不会。
每次回测完,先看交易笔数。如果一轮回测只有一个 BUY 没有对应的 SELL,策略从一开始就死了。再看持仓时间分布,如果某个仓位从开仓一直拿到回测结束,那不是”长期持有策略”,那是卖出信号没生效。
最后核对状态终值。回测结束时 in_position 应该和最后一次交易方向一致——如果最后一笔是 BUY 但 in_position == False,或者反过来,状态机就出问题了。
把这三个检查写成回测报告的必检项,比任何花哨的风控逻辑都管用。
copy-paste 不可怕,可怕的是它制造的错误是”静默正确”——代码能跑,结果能出,只是结果全是假的。状态断言、对称结构、属性测试,三道防线把这种沉默的 bug 变成响亮的报错。