pineforge
開始使用
工程

訊號時 vs 成交時的保證金檢查:一則 TradingView 對齊故事

我們的撮合仿真在成交時用下一根 K 棒開盤價做保證金門檻;TradingView 在訊號時用當根收盤價。價差 3 美分,卻讓 2,632 筆交易的策略悄悄少了 25 筆。六支隔離探針如何把 bug 收斂到一行 C++。

約 11 分鐘閱讀#parity#engine#tradingview#debugging#broker-emulation

我們一致性語料庫裡有一支社群 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.lineta.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_entryprocess_pending_ordersenter_market_from_flatapply_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=0

strategy.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=14

fill.kind=0FillEvaluation::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 美元

Price usedRequiredEquityOvershoot
Signal close ($1566.66)$985,319.47$985,319.43+$0.04
Fill open ($1566.69)$985,338.34$985,319.43+$18.91

ETH 只差 三美分,乘以 628.93 張合約,就多出 18.91 美元所需保證金。引擎的保證金檢查發生在成交時刻,用的是成交價——於是被拒。我們現在的判斷是:TradingView 的經紀模擬器在同一檢查上用的是訊號時刻訊號收盤價——於是放行,因為在約 98.5 萬美元的名義曝險上,四美分量級的差值落在 TV 在邊界上使用的浮點容差之內。

再用三根探針交叉驗證

為了證明這不是單筆巧合,且問題確實卡在 1× 權益邊界,我們又做了三根探針把它 bracket 住:

ProbeSizingMargin pressure
parity-probe-04-percent-of-equity-sizingdefault_qty_type=percent_of_equity, default_qty_value=99~99% equity
parity-probe-05-small-equity-fractionqty = round(equity / close * 100) / 1000~10% equity
parity-probe-06-edge-margin-sizingqty = round(equity * 0.5 / close * 1000) / 1000~50% equity

三根都發布到 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%
  -> excellent

PnL 漂移從 2.49% 降到 0.78%,IES 從 moderate 升到 excellent。語料庫其餘部分:168 支策略裡 165excellent2strong0moderate。全部 16 個 ctest 二進位檔通過,全部 313 個 codegen pytest 通過,其它 161 支社群或驗證策略無迴歸。

反轉

修完之後,parity-probe-03-equity-mirror 本身與 TV 仍未完全對齊:引擎 25 次進場,TV 24 次,其中只有 13 次重疊。探針-03 的設計就是緊貼 1× 權益邊界,因此兩邊在容差上的任何細微分歧都會最先在這裡暴露。

我們對比對視窗內每個週一計算 qty * signal_close - equity,並記錄 TV 的接受/拒絕結果:

MondayOvershoot at signal closeTV action
2025-04-07+$0.04TAKE
2025-04-14-$0.76TAKE
2025-04-21-$0.19TAKE
2025-04-28+$0.34REJECT
2025-05-12-$0.18REJECT (under equity, yet rejected)
2025-05-26-$0.89REJECT (under equity, yet rejected)
2025-08-18+$0.24REJECT
2025-11-10+$0.24TAKE (identical overshoot, opposite outcome)

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 調和的角落寫文件

接下來可以做什麼