工程

252 个策略、3 个隐藏 Bug:我们在扩充一致性语料库时学到的事

语料库从 167 个策略扩充到 252 个,99.6% 达到优秀一致性。过程中浮出三个未建模的 TradingView 经纪商行为:强平时机、跨币种购买力检查、trail_price 移动止损从未上膛。
约 8 分钟阅读#parity#corpus#engine#tradingview#broker-emulation

我们上次发布语料库数据是在 5 月,当时一致性语料库里有 167 个参照策略,其中 165 个对 TradingView 做到逐笔一致。这之后语料库还在长。现在是 252 个策略——251 个达到「优秀」一致性(99.6%),1 个有文档记录的异常,0 个失败。

这个总数本身没什么意思。有意思的是过程中浮出来的东西:三个我们之前没建模的 TradingView 经纪商行为,每一个都是因为某条真实或合成策略走到了原来 167 条样本没覆盖到的代码路径才暴露出来的。下面逐一过一遍。

语料库现状

corpus/validation_report.md (2026-06-29)
  Total probes:    252
  Excellent:       251  (99.6%)
  Anomaly:           1
  Strong/Moderate/Weak/Minimal/Fail: 0
 
  TV trades:      389,590
  Engine trades:  389,688
  Matched:        389,468

引擎在整个语料库上多出 98 笔成交——相对 389,590 笔参照成交,偏差 0.025%。这是聚合口径;拆到单策略上看,几乎全部都落在下面那一个异常上,不是均匀散在其余 251 个里的噪声。

最新的六条样本属于 drawing 类别,是我们上线「把 Pine 画图对象当回测期数据读取」的运行时之后加入的——line.get_price、label/box 几何信息,不再只是图表标注。如果一个策略把手画的线当成计算状态用(比如手动锚定的趋势线被当成动态支撑位),引擎就得像 TradingView 一样,继续为脚本已经删除的对象提供几何信息。这六条全部通过,干净。

那一个异常,老实交代

不达标的那一条策略我们没有过滤掉。anomaly-equity-mirror-strategy-equity-01 正好卡在保证金准入的 1 倍权益边界上。TradingView 的经纪商模拟器在这条边界上是非单调的——逐 bar 不一致地放行一些超权益的开仓、又拒绝一些未超权益的开仓。我们的引擎是确定性的:同样的权益,每次都给出同样的准入判断。按 Pine 文档里的保证金语义来说,这是对的。但也确确实实,不是 TradingView 经纪商在这条边界上实际的行为。要对齐它,我们得自己引入不确定性,所以没这么做。这个差距是写进文档的,不是藏起来的,而且 252 条里只有这一条。

Bug 一:TV 会强制平掉、我们没平的仓位

TradingView 的经纪商不会等你的策略逻辑自己决定何时退出一个已经突破保证金要求的仓位——它直接强平。如果一个带杠杆或做空的仓位权益跌到所需保证金超过账户权益的程度,TradingView 会在下一根 bar 强平该仓位,不管 strategy.exit() 的条件满足还是没满足。

引擎之前没建模这一点。它会继续持有仓位,让策略自己的逻辑迟早去平——对于没设紧止损的策略,这意味着可能要再扛几十根 bar,而这段时间 TradingView 早就把仓位强平了。在语料库里带明显杠杆的策略上,这在压力行情下会跑出差异巨大的权益曲线,即便入场和出场逻辑在其他地方完全吻合。

修复方案是在账户权益跌破保证金要求时触发强平,用和 TradingView 经纪商一样的方式计算强平价格,然后把强平数量量化到该交易对的 lot step——TradingView 不会强平小于交易所允许步长的零碎仓位,我们现在也一样。

这对在永续合约上回测杠杆或做空策略的人影响最大。如果你的权益曲线在回撤阶段看起来过于平滑,这很可能就是原因。

Bug 二:不认汇率的购买力检查

Pine 策略可以声明一个跟交易对计价货币不同的账户货币——比如用 currency.INR 去交易一个 USDT 对。TradingView 的经纪商在检查你是否买得起一笔订单之前,会先按当时的汇率把订单名义价值换算成你的账户货币,再拿换算后的数字去跟同币种的权益比较。

引擎的购买力检查少了这一步换算。它直接拿 USDT 名义价值去跟以 INR 计价的权益比,相当于假设汇率是 1:1——也就是说一个声明 currency.INR 的策略,看起来比实际能负担的多出大约 83 倍的购买力(因为 1 USDT 大致值这么多卢比)。受影响的脚本上,这大致让引擎多放行了一倍本该被 TradingView 经纪商判定为买不起而拒绝的成交。

修复方案是在购买力检查之前,先把账户货币的汇率应用到所需保证金上——这正是 TradingView 在订单处理流程里应用汇率的那个节点。如果你在用非美元计价的账户货币去回测一个以美元或 USDT 计价的交易对,这就是那个悄悄把你的成交笔数吹高的 bug。

Bug 三:从未上膛的移动止损

这是我们最希望用户能在我们之前发现的一个,因为它失败得无声无息。strategy.exit() 支持两种方式指定移动止损:trail_points,相对入场价的距离;trail_price,绝对的激活价格。

// armed correctly today; trail_price-only calls used to never arm at all
strategy.exit("TS", from_entry = "Long", trail_price = close * 1.05, trail_offset = 10)

引擎的上膛条件之前只检查了 trail_points。一个纯用 trail_price 指定的移动止损,编译没错、运行没错,但从来没有上膛过——仓位就那么挂着,整个回测期间没有任何移动止损在生效,悄无声息,没有异常、没有日志、没有那一笔平仓成交。策略看起来是有保护的。其实没有。

我们发现这个问题是因为有一条语料库样本专门用来隔离测试 trail_price 单独激活的场景——这正是 validation 这个类别存在的意义,正如语料库结构那篇文章里说的:不追求盈利、不追求真实,就是为了单独触发某一个 Pine 特性,让这类回归 bug 没法藏在"整体数字看起来还行"后面。

修复方案是只要 trail_pointstrail_price 任一个存在就上膛。如果你写过用 trail_price 的 Pine 策略,并且权益曲线看起来疑似完全没有移动止损在起作用——那确实没有,这次修复之前的任何引擎版本上都是这样。

为什么这才是语料库真正的意义

这三个 bug 都不是靠代码审查找出来的。是因为某条形状刚好合适的策略跑过引擎,吐出了一个跟 TradingView 对不上的数字。这正是我们坚持把167 个策略,到 252 个,还在增加当成一个留存、有版本号的语料库去维护,而不是随手写几个 smoke test 的全部理由:不改变成交笔数、不改变开仓价的 bug,照样可以改变最重要的那个数字——策略到底赚了还是亏了——而能抓到这类问题的唯一办法,就是不断拿更多形状的策略去对同一份 ground truth 做 diff。

我们会持续补充样本去填补已知的空白,也会持续公布这些数字——好的部分,和那一个异常,都一样。

接下来可以看看