訊號時 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 次轉譯,足夠開始搭建你自己的本機一致性語料庫。