工程

252 支策略、3 個 TradingView 券商行為、1 筆誠實標出的異常

語料庫從 167 支長到 252 支,99.6% 達到 excellent 一致性。過程中揪出三個沒被建模的 TradingView 券商行為:強制平倉、FX 購買力檢查、從未上膛的移動停損。
約 9 分鐘閱讀#parity#corpus#engine#tradingview#broker-emulation

我們上一次發布語料庫數字是五月,當時一致性語料庫有 167 支參考策略,其中 165 支對 TradingView 達到逐筆對齊的嚴格一致性。這幾個月語料庫又長大了。現在是 252 支策略——251 支落在「excellent」一致性等級(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

整個語料庫裡,引擎比 TradingView 多吐出 98 筆成交——相對 389,590 筆參考成交,是 0.025% 的差距。但這是加總後的數字;拆開來看,幾乎全部都集中在下面那一筆異常上,不是均勻散布在其他 251 支策略裡的雜訊。

最新的六筆 probe 屬於一個新的 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() 的出場條件當下是否成立。

引擎原本沒有建模這個行為,會繼續持有部位,放任策略自己的邏輯之後再去平倉——對一支沒有設緊密停損的策略來說,這可能意味著 TradingView 早就清倉的部位,引擎還多扛了幾十根 bar。在語料庫裡那些槓桿用得比較重的策略上,這會在壓力情境下產生天差地遠的權益曲線,即便進出場邏輯在其他地方完全一致。

修法是在帳戶權益跌破保證金要求時加入強制平倉,用跟 TradingView 券商一樣的算法算出平倉價,再把平倉數量量化到該標的的 lot step——TradingView 不會平倉小於交易所允許單位的零碎倉位,現在我們也不會。

對那些在永續合約上回測槓桿或放空策略的人來說,這個影響最大。如果你的權益曲線在回撤段看起來「太平滑」,這很可能就是原因。

Bug 二:不知道有 FX 這回事的購買力檢查

Pine 策略可以宣告一個跟標的計價貨幣不同的帳戶貨幣——比如用 currency.INR 去交易一個 USDT 的對。在檢查一筆訂單買不買得起之前,TradingView 的券商會先用當下的 FX 匯率,把訂單的名目價值換算成你的帳戶貨幣,再拿換算後的數字跟同貨幣計價的權益比較。

引擎的購買力檢查少了這一步換匯。它直接拿 USDT 名目價值跟 INR 計價的權益相比,等於假設 FX 匯率是 1:1——也就是說,一支宣告 currency.INR 的策略,會看起來比實際情況「便宜」大約 83 倍(因為 1 USDT 大約值這麼多盧比)。受影響的腳本上,這大概讓引擎多放行了一倍的成交數量,而這些成交本該被 TradingView 的券商判定為買不起而拒絕。

修法是在購買力檢查跑之前,先對所需保證金套用帳戶貨幣的 FX 匯率,套用的時機點跟 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 指定的移動停損會正常編譯、正常執行,然後從頭到尾都沒上膛——部位就這樣靜靜地掛在那裡,整段回測期間沒有任何移動停損在運作,沒有例外、沒有 log,什麼都沒有。策略看起來是有防護的。其實沒有。

我們是因為語料庫裡有一支 probe 專門用來單獨測試 trail_price-only 的啟動行為才抓到這個——這正是 validation 這個分類存在的意義,如同語料庫拆解那篇文章說的:不求賺錢、不求真實,就是為了單獨打中某一個 Pine 功能點而存在,這樣像這次的回歸才不會被「整體數字看起來沒問題」蓋過去。

修法是讓移動停損只要 trail_pointstrail_price 任一個存在就上膛。如果你曾經寫過用 trail_price 的 Pine 策略,而權益曲線看起來疑似完全沒有移動停損在跑——那是真的沒有,在這個版本之前的所有引擎版本上都一樣。

為什麼這才是語料庫真正的意義

這三個 bug 沒有一個是 code review 抓出來的。它們都是因為某支形狀剛好對的策略跑過引擎,吐出一個跟 TradingView 不一樣的數字,才被揪出來。這正是我們堅持把167 支、然後 252 支,而且還在增加的策略當成一個保留、有版本控制的語料庫,而不是隨便幾支 smoke test 的核心理由:有些 bug 不會改變成交筆數,也不會改變進場價,卻會改變那個最重要的數字——策略到底賺了還是賠了——而要抓到這種 bug,唯一的辦法就是不斷拿更多形狀的策略去跟同一份 ground truth 做 diff。

我們會持續在發現缺口的地方加入新的 probe,也會持續公開這些數字——不管是好的結果,還是那唯一一筆異常。

接下來可以做什麼