信号时 vs 成交时的保证金检查:一则 TradingView 对齐故事
我们的撮合仿真在成交时用下一根 K 线开盘价做保证金门槛;TradingView 在信号时用当前收盘价。两者相差 3 美分,却让 2,632 笔交易的策略悄悄少了 25 笔。六个隔离探针如何把 bug 收敛到一行 C++。
我们一致性语料库里有一条社区 Pine 策略叫 IES,几周以来对 TradingView 的一致性评级一直是 moderate。成交笔数对得上。开仓价对到分。平仓价也对到分。但在 2,632 笔已对齐的成交上,累计盈亏却偏了 29,281 美元。
本文记录我们如何定位根因:一笔保证金检查比正确时机晚了「四美分」。
不对称的指纹
在我们深挖之前,verify_corpus.py 对该策略的报告大致如下:
community/IES (pre-fix)
Profile: strict
TV trades: 2668 (raw 2668)
Engine trades: 2663 (raw 2663)
Matched: 2632 (98.7% of TV)
Count delta: 0.1874% (OK)
Entry-price p90 delta: 0.0000% (OK)
Exit-price p90 delta: 0.0000% (OK)
PnL p90 delta: 2.5412% (X)
-> moderate这一行本身就很说明问题:当笔数、开仓价、平仓价都对齐,而单笔盈亏却漂出约 2.5% 时,通常不是信号逻辑或成交价的问题——两边在同一价位选了同一批单子,仓位大小却不同。
从对比窗口的最后一笔往前反推,用「回解仓位输入」复现出的缺口,与累计权益差 29,281 美元 完全一致。分歧出在 strategy.equity 上——这是每个仓位公式都依赖的递归反馈变量,而不是某一笔成交里的算术。
我们先试了哪些方向(以及为何都不对)
在命中正确假设之前,我们先后推翻了三条错误路径。值得写出来,因为真实调试往往就是这样:大部分时间是在证伪,而不是在证实。
指标预热不一致。 引擎在对比窗口开始前会用五个月 OHLCV 预热技术分析栈;TradingView 图表上的历史未必有那么长。我们搭了按 K 线追踪 TA 的 harness,过程中还真的揪出了八个指标实现问题——ta.pivothigh / ta.pivotlow 语义、ta.iii 公式、ta.tsi 代码生成与缩放、ta.cog 权重、ta.bbw 百分比、ta.vwap 日锚、ta.supertrend.line、ta.pivot_point_levels 上一根 K 的 HLC,以及 ta.cross 跳过平盘 tie 的语义。八个都修了,IES 的指标却纹丝不动。
止损 / 限价成交时机。 另一条社区策略(scalping-wunder-bots)上,引擎比 TradingView 早一根 K 线平仓的模式很干净。我们假设引擎在开仓那根 K 上就评估了 strategy.exit 的止损,而 TV 推迟到下一根。于是建了 parity-probe-01-stop-limit-timing 专门隔离这一点,结果是 778 / 778 笔全部对齐,四条 strict 门槛全绿。假设被证伪。
CHoCH / BOS 摆动跟踪。 还有一条策略(VCP)在 2025-05-12 12:15 多出一笔疑似「枢轴状态演化不一致」的入场。我们建了 parity-probe-02-choch-bos-isolator,镜像其枢轴签名。1026 / 1026 对齐。 再次证伪。
三轮探针下来,IES 没有改善,但多了一条方法论:写最小化的 Pine 策略、一次只隔离一个假设,比在一千多行的社区脚本里追内部变量快得多。于是我们写了第四个。
真正揪出问题的探针
parity-probe-03-equity-mirror 的巧思在于:下单数量本身就把 strategy.equity 编码进去了:
//@version=6
strategy("Parity probe 03 - equity mirror",
shorttitle="par_p03", overlay=true, initial_capital=1000000,
default_qty_type=strategy.fixed, default_qty_value=1,
pyramiding=0, process_orders_on_close=false,
commission_value=0, slippage=0)
bool fire = dayofweek == 2 and hour == 0 and minute == 0
and strategy.position_size == 0
float qty_dyn = math.round(strategy.equity / close * 1000) / 1000
if fire and qty_dyn > 0
strategy.entry("E", strategy.long, qty=qty_dyn)
if strategy.position_size > 0 and bar_index >
strategy.opentrades.entry_bar_index(0)
strategy.close("E")qty = round(strategy.equity / close * 1000) / 1000 保留三位小数,意味着 TradingView「成交列表」CSV 里导出的 Size (qty) 列,会变成 TV 端 strategy.equity 的按 K 线可观测预言机。把引擎与 TV 的成交按开仓时间戳对齐,再并排比对 qty,就能直接从 diff 上读出权益分歧。
前八笔对齐后的成交:
2025-03-31 00:15 eng_qty=553.1490 tv_qty=553.1490 diff=+0.000%
2025-04-14 00:15 eng_qty=612.9970 tv_qty=612.1600 diff=+0.137%
2025-04-21 00:15 eng_qty=623.1630 tv_qty=622.3110 diff=+0.137%
2025-08-25 00:15 eng_qty=207.2020 tv_qty=209.8110 diff=-1.244%
2025-11-03 00:15 eng_qty=242.4760 tv_qty=251.9860 diff=-3.774%
2025-11-24 00:15 eng_qty=339.1820 tv_qty=361.0170 diff=-6.048%
2025-12-22 00:15 eng_qty=307.0180 tv_qty=330.5680 diff=-7.124%
2026-01-05 00:15 eng_qty=295.2650 tv_qty=320.1020 diff=-7.759%第一笔两边完全一致,两边都从 100 万美元权益起步。从第二笔起 qty 列分开,再也没有收敛。到第八笔,缺口大致稳定在约 7.76%。
根因不是计算慢慢漂了,而是少做了一笔成交。本应作为第二笔、日期为 2025-04-07 的入场出现在 TV 的成交列表里,却没有出现在引擎里。TV 做了这笔单,吞了约 1,344 美元亏损,权益掉到大约 98.4 万;引擎没做,权益停在大约 98.5 万。从那以后,引擎在后续每一笔上的仓位都比 TV 更大,累计亏损沿着不同的权益路径发散,十三个月后缺口变成 29,281 美元。
幽灵成交
我们在 strategy_entry、process_pending_orders、enter_market_from_flat、apply_market_order_fill 里加了 std::cerr 日志,并用环境变量门控,免得噪声污染其它测试。
信号 K 线,2025-04-07 00:00:
[entry CALL] ts=1743984000000 bar=16236 id=E is_long=1 qty=627.312
pending_orders.size=0 pos_qty=0 pos_side=0strategy.entry 被调用了,订单进了队列。到这里还正常。
成交 K 线,2025-04-07 00:15:
[ppo] ts=1743984900000 bar=16237 pass=0 i=0 id=E type=0 is_long=1
qty=627.312 eligib=FILL_TRY
-> fill.kind=0 fill_price=1566.69
-> AFTER apply: pos_qty=0 pos_side=0 pyramid.size=0 trades.size=14fill.kind=0 即 FillEvaluation::Kind::Fill。价格评估器说可以成交,apply_filled_order_to_state 也调用了——持仓状态却没有更新。一笔幽灵成交。
再在 enter_market_from_flat 里加一行调试,打印保证金守卫的输入:
[margin] ts=1743984900000 qty=627.312 fill_px=1566.69 margin_pct=100
required=982803 available_equity=982785 net_profit_sum=-17215.4
initial=1e+06 block=YES所需保证金:982,803 美元;可用权益:982,785 美元。保证金守卫认为账户还缺 18.91 美元,于是提前 return,既不更新状态,也没有报错或对用户可见的日志。从策略视角看,这笔成交根本不存在。
为何 TradingView 却做了同一笔
相关两根 K 的 OHLC 如下:
04-07 00:00 (signal): O=1579.98 H=1585.57 L=1566.00 C=1566.66
04-07 00:15 (fill): O=1566.69探针在信号收盘价上定仓:qty = round(985319.43 / 1566.66 * 1000) / 1000 = 628.93。在信号收盘价上算出的所需保证金与可用权益刚好对上;在下一根开盘价上算,则多出 18.91 美元。
ETH 只差 三美分,乘以 628.93 张合约,就多出 18.91 美元所需保证金。引擎的保证金检查发生在成交时刻,用的是成交价——于是被拒。我们现在的判断是:TradingView 的经纪模拟器在同一检查上用的是信号时刻与信号收盘价——于是放行,因为在约 98.5 万美元的名义敞口上,四美分级别的差值落在 TV 在边界上使用的浮点容差之内。
再用三根探针交叉验证
为了证明这不是单笔巧合,且问题确实卡在 1× 权益边界,我们又做了三根探针把它 bracket 住:
三根都发布到 TradingView,导出成交列表,再跑 diff:
parity-probe-04: TV=57 engine=57 matched=57/57 (100.0%) PnL p90=0.0006% → excellent
parity-probe-05: TV=57 engine=57 matched=57/57 (100.0%) PnL p90=0.0698% → excellent
parity-probe-06: TV=57 engine=57 matched=57/57 (100.0%) PnL p90=0.0700% → excellent当仓位没有死死钉在 1× 保证金边界上时,三根探针各自 57 笔成交上引擎与 TV 完全一致。问题只在边界上才会露头——信号收盘与成交开盘之间的滑移足以翻转保证金判定。
修复方案
把保证金检查从 enter_market_from_flat(在成交时刻用成交价跑)挪到 strategy_entry(在信号时刻用当前 K 收盘价跑):
void BacktestEngine::strategy_entry(const std::string& id, bool is_long,
double limit_price, double stop_price,
double qty, ...) {
if (!trading_is_active(...)) return;
+
+ // TradingView broker rule: market-entry orders are admitted only
+ // when qty * <signal-bar close> * margin_pct/100 <= equity. Check
+ // happens HERE at signal time with current_bar_.close, NOT later
+ // in apply_market_order_fill where fill_price is the next bar's
+ // open. Verified empirically by parity-probe-{03..06}.
+ if (!std::isnan(qty) && std::isnan(limit_price) && std::isnan(stop_price)) {
+ double margin_pct = is_long ? margin_long_ : margin_short_;
+ if (margin_pct > 0.0 && !std::isnan(current_bar_.close)) {
+ double required = std::abs(qty) * current_bar_.close * (margin_pct / 100.0);
+ double available = current_equity();
+ double epsilon = std::max(1e-9, std::abs(available) * 1e-12);
+ if (required > available + epsilon) {
+ return;
+ }
+ }
+ }
...新增约五行,并在 enter_market_from_flat 里删掉对应旧逻辑。对 IES 重跑:
community/IES (post-fix)
TV trades: 2668 (raw 2668)
Engine trades: 2643 (raw 2643)
Matched: 2618 (98.1% of TV)
Count delta: 0.9370% (OK)
Entry-price p90 delta: 0.0000% (OK)
Exit-price p90 delta: 0.0000% (OK)
PnL p90 delta: 0.7809% (OK) ← was 2.5412%
-> excellentPnL 漂移从 2.49% 降到 0.78%,IES 从 moderate 升到 excellent。语料库其余部分:168 条策略里 165 条 excellent,2 条 strong,0 条 moderate。全部 16 个 ctest 二进制通过,全部 313 个 codegen pytest 通过,其它 161 条社区或校验策略无回归。
反转
修完之后,parity-probe-03-equity-mirror 本身与 TV 仍未完全对齐:引擎 25 次入场,TV 24 次,其中只有 13 次重叠。探针-03 的设计就是紧贴 1× 权益边界,因此两边在容差上的任何细微分歧都会最先在这里暴露。
我们对对比窗口内每个周一计算 qty * signal_close - equity,并记录 TV 的接受/拒绝结果:
8 月 18 日与 11 月 10 日同样是 +$0.24 的超调,结果却相反。任何纯保证金规则——任意阈值、任意容差——都无法同时解释这种模式。TV 经纪模拟器在这一边界上依赖的东西,并不出现在任何已文档化的 Pine 变量里:可能是图表加载历史、用户发布时 K 线对策略「变得可见」的顺序,或某种我们无法观测的内部状态。
坦率地说,这是 TradingView 经纪模拟器里一处未文档化的角落,只在仓位精确钉在 1× 权益边界时才会显现。真实策略通常会留余量——例如 community/IES 每笔大约只用权益的 1%~1.4%——因此不会咬到普通用户。我们选择保留这次修复,并把探针-03 标为已知局限;诊断探针在「观感层级」上降一级,换来 IES 这一胜,代价是公平的。
这次调试带给我们的六点认识
1. 不对称的指标指纹能指向 bug 类别。 笔数、开平仓价都对齐而 PnL 漂,多半就是仓位/资金路径类问题;我们本可以更早去看与 sizing 相关的代码,而不是花几周审计指标公式。
2. 隔离探针比在大策略里追变量更可扩展。 六个小 Pine 探针、各测一个假设,两个下午就定位到问题;若在七百行社区脚本里追每个内部变量与权益反馈,会更慢、噪声更大——这和写单测而不是只在集成层调试是同一类道理。
3. TradingView 的 qty= 在语义上绑定的是「信号时刻」的保证金检查。 检查发生在调用 strategy.entry 的那根 K 上,而不是成交 K。Pine 参考手册里没有写清楚。任何要做 Pine 兼容经纪模拟器的实现,都应该对齐这一点;直觉实现往往把检查放在成交时刻(此时成交价已知),会静默出错。
4. 边界上的浮点永远是敌人。 触发约 2.9 万美元复合漂移的那一笔,只是在约 98.5 万美元名义上四美分量级的超调。生产策略最好在 sizing 公式里留 1%~5% 保证金缓冲(例如 * 0.99 或 * 0.95),根本不要贴在边界上过日子。
5. 复利会放大「单笔漏单」。 仅 04-07 那一次漏单,直接损失约 1,344 美元;十三个月后滚成约 2.9 万美元缺口——大约 22 倍——因为每一笔漏单都会改写权益路径,从而改写下一笔的仓位与下一笔的接受/拒绝。带权益反馈的策略对孤立的执行缝隙极不宽容。
6. 有时问题在「对面」,你只能如实写进文档。 TV 在 1× 边界上的行为确实是我们在自己这边无法完全抹平的角落;坦诚写进文档,好过为客观上对不齐的指标默默空转。
这套方法不局限于这一只 bug。一致性探针是我们 PyneCore 交叉校验 sweep 的互补:PyneCore 告诉我们引擎与另一套引擎何时不一致;一致性探针告诉我们与 TradingView 为何不一致。以后遇到新缺口,流程就是:写能复现它的最小 Pine → 发布到 TV → diff 成交列表 → 给引擎加插桩 → 修分歧 → 对无法与 TV 调和的角落写文档。
接下来可以做什么
- 在 Claude 或 Cursor 里试用 codegen API——把自有 Pine 策略转译并在本地 OHLCV 上跑。
transpile_pine工具会返回 C++ 输出,便于查看保证金检查现在的接线方式。 - 浏览图库——全部 162 条策略及当前一致性层级;community/IES 卡片现已显示
excellent。 - 申请早期访问——免费档每月 100 次转译,足够开始搭建你自己的本地一致性语料库。