Pine v6 的型別系統:實務筆記
PineScript v6 比你想的更強型別。走一遍規則與推導、常見坑——以及我們做 transpiler 後學到的:哪些規則在執行期才真正重要。
Pine Script 常被說成弱型別。這種說法有一半屬實,多半不對。之所以貼上這個標籤,是因為 int 會無聲地拓寬到 float,na 幾乎隨處可用,而且語言很少要求你寫明示註解。但在這層寬鬆表象之下,是一套真正有約束力的型別系統——而一旦你要把它編譯到靜態型別語言裡,每一處細節都會較勁。
本文梳理 Pine v6 的型別到底是什麼、推論層如何把複雜度藏起來,以及我們在搭建 C++ 程式碼產生時最常踩的三個坑。
名聲與現實
新手會寫:
x = 5
y = close + x沒有註解,編譯器也不吭聲,於是得出結論:「Pine 沒有型別。」實際上 Pine 精確推論出了變數意義:x 是 simple int(編譯期常數整數),y 是 series float(逐根 K 線的浮點值)。推論在做實事,只是看不見而已。
simple int 與 series float 的差別並非擺設:執行行為、運算子相容性,以及轉譯輸出裡的表示都不同。語言是有型別的;它只是不強迫你把型別寫出來。
型別的層次
Pine v6 的型別體系有四層。多數人只會碰到第一層。
原始型別
原始型別包括 int、float、bool、string、color。此外還有下文單獨討論的 na。它們是存放純量值的葉子型別。
int 與 float 參與隱式拓寬:在同一運算式裡混用時,int 會升為 float。這正是 Pine 被喊「弱型別」的原因,但這是刻意且一致的規則,並非特例。凡是強型別語言,對數值型別多半也會這麼做。
複合型別
Pine v6 支援三種參數化複合型別:array<T>、matrix<T>、map<K, V>。元素型別必須是 Pine 型別(含 UDT)。可以有 array<float>、array<int>,乃至 array<MyUDT>。
複合型別是可變物件。就「參考」而言,把 array<float> 傳給函式,函式拿到的是同一塊底層儲存的控點;函式改陣列,呼叫端看得到。
使用者自訂型別(UDT)
Pine v6 引入 type 關鍵字,可定義帶欄位與可選方法的具名紀錄型別:
type Order
float price
int qty
bool filled = false
method fill(Order this, float fillPrice) =>
this.price := fillPrice
this.filled := trueUDT 最接近結構體。欄位可有預設值(如 filled = false)。方法用點號呼叫,實例作為 this 傳入。欄位可用 := 變更。
形態限定詞:series 與 simple
Pine 中的每一個值——不僅是原始型別,也包括複合型別與 UDT——都帶有一個 形態限定詞,描述它與 K 線時間軸的關係。
series— 值可以逐根變化,有歷史:x[1]表示上一根上的x。涉及價格資料的運算式大多預設如此。simple— 值在整個策略執行期間固定,沒有可用的歷史(對simple值存取x[1]在多數語境下是編譯錯誤)。const— 編譯期已知,例如const int VERSION = 6。可被程式碼產生折疊,不出現在最終二進位裡。
形態限定詞構成層級:const ⊂ simple ⊂ series。混合形態的運算式會升到最高(最一般)的形態。simple int + series float 的結果是 series float。
na 的語意
na 不是萬用的 null。它是帶型別的值——na<int> 與 na<float> 不同,具體型別由上下文推論。例如:
x = naPine 預設把 x 推論為 series float(對未初始化序列最常用的預設)。若在受限上下文——例如之後又寫 x := 5——推論會鎖定為 int。
對 na 做算術會傳播:na + 1 仍是 na。這是有意且一致的:值未知,則依賴它的運算式也未知。退路是 nz(x, 0),用預設值取代 na。
比較運算子同理:na == na 是 na,不是 true。這是最常見的誤區。要檢驗是否缺值,用內建述詞 na(x),不要用 x == na。
在我們的 C++ 程式碼產生裡,na 對應為 std::optional<T>。對 std::nullopt 做算術得到 std::nullopt。na(x) 變為 !x.has_value()。語意對齊得很乾淨;C++ 會更囉嗦。
型別推論細說
Pine 從指派右側推論型別。一旦掌握形態限定詞的層級,規則就很直白:
a = 5 // const int
b = 5.0 // const float
c = close // series float (close is always series float)
d = close + 1 // series float (int widens to float; series promotes)
e = bar_index // series int (bar_index is series int, always)有意思的是跨分支推論:
x = condition ? close : 0condition 可能是 series bool。close 是 series float。0 是 const int。Pine 把 0 拓寬到 series float,結果為 series float。編譯器永遠取最一般的形態與最兜底的型別。
函式引數也會約束推論。若內建函式要求 simple int,你卻傳入 bar_index(series int),就會編譯失敗。這時型別系統的嚴厲才浮出檯面:不能把逐根變化的值傳給要求在啟動時固定的參數。
C++ 程式碼產生如何表示這些型別
把 Pine 的型別體系譯成 C++,需要把每種形態與每種 Pine 型別對應到執行行為正確的 C++ 表示。
原始型別對應 double、int64_t、bool、std::string。Pine 的 float 始終是 64 位元;我們一律用 double。
**序列(series)**用帶模板的惰性求值歷史緩衝區表示。抽象對外暴露:
template <typename T>
class Series {
public:
T current() const;
T at(int bars_ago) const; // implements the [] operator
void advance(T next_value);
};Pine 寫 close[2] 時,程式碼產生輸出 close.at(2)。一根 K 線走完,引擎會對每條序列呼叫 advance() 平移歷史視窗。
**na**對應為 std::optional<T>。Series<std::optional<T>> 上的算術運算子會像 Pine 傳播 na 那樣傳播 nullopt。內建 nz() 變為 x.value_or(default_value)。
UDT變成普通的 struct。欄位即成員,方法即成員函式。Pine 的欄位指派運算子 := 就是普通 C++ 指派,沒有 setter。
陣列與矩陣分別為 std::vector<T>,以及包在 std::vector<T> 外的薄二維封裝。
我們踩得最多的三個坑
1. 混合 series 與 simple 會升到 series——即便你以為會得到常數
myLength = 14
ma = ta.sma(close, myLength)若 myLength 在全域宣告且從不改動,你可能期望程式碼產生把它當編譯期常數。Pine 推論它為 simple int,而不是 const——值在執行啟動時固定,而非編譯期。於是 ta.sma(close, myLength) 是 series float,不是編譯器能折疊的東西。
對我們的程式碼產生而言,我們曾希望為效能對某些視窗長度做常數折疊。Pine 的型別系統合理地拒絕:simple 不是 const,沒有寫 const 的變數原則上還可能透過 input.int() 設定——那是啟動期決定,不是編譯期決定。
2. UDT 欄位指派不會觸發方法——這是語言設計
type Box
float value
method doubled(Box this) =>
this.value * 2
b = Box.new(value = 10.0)
b.value := 20.0 // direct field write, no notification在物件導向語言裡,你可能期待 b.value := 20.0 走 setter,因而呼叫 doubled() 之類。Pine 不會。欄位公開且可直接改寫。方法只是掛在型別上的函式。若你在 UDT 裡做狀態機且想在寫入時驗證,必須明示呼叫負責驗證的方法——欄位寫入繞過一切。
我們在內部文件裡強調這一點,因為這是 Pine 程式看起來有封裝、實則沒有的主要位置。C++ 程式碼產生如實反映:欄位寫就是普通結構成員指派。
3. request.security 預設因果——但 lookahead 預設值隨版本變過
htf_close = request.security("BTCUSDT", "D", close)呼叫在型別上合法,編譯也無警告。回傳的是 目前這根 K 線時刻下 的日線收盤價。若目前是盤中 15 分鐘線,htf_close 在新日線收盤之前都帶著前一日的收盤——因果解釋。
歷史包袱:Pine v4 裡 request.security 預設 lookahead=barmerge.lookahead_on,可在日線未收盤前偷看未來日線,抬高使用 HTF 的策略的回測成績。Pine v5 把預設改為 lookahead_off(因果),這才是正確行為。Pine v6 延續 v5 預設。
型別系統不會提醒你,因為兩種模式都型別合法。PineForge 程式碼產生預設採用 lookahead_off 語意,與 Pine v6 參考行為一致。若你從 v4 移植策略,在 PineForge 上回測數字大變——多半是這個原因。
為什麼轉譯 Pine 逼我們更嚴謹
Pine 型別規則裡的每一處含糊,都必須在 C++ 程式碼產生裡落成確定選擇。參考文件寫「由實作定義」,或兩種行為都與規格相容時,我們不能隨手猜。必須對照 TradingView 的真實輸出,弄清它走了哪條分支,再實作那條分支。
這逼我們把 Pine v6 語言參考讀得比一般使用者細得多,也產出了大部分一致性測試:若在 request.security 或 strategy.exit 的邊角上程式碼產生與 TradingView 不一致,就寫最小重現,加入 validation 語料,修到兩邊輸出對齊為止。
型別系統是邊角案例的棲地。形態限定詞——series、simple、const——看起來像實作細節,卻決定哪些運算合法、哪些值能傳給哪些函式、哪些運算式能在編譯期求值而不是逐根求值。做對這套,才是「能用的轉譯器」和「好用的轉譯器」的分水嶺。
接下來
- 在 Claude 或 Cursor 裡試用程式碼產生 API——轉譯你自己的 Pine v6 策略,用本機 OHLCV 跑。若想檢視型別表示,
transpile_pine會回傳 C++ 輸出。 - 瀏覽圖庫——
validation分類下有 142 個專為擠壓型別邊界而寫的策略;成交筆數能看出哪些策略足夠「熱鬧」,容易暴露錯誤。 - 讀我們為何要做這件事——引擎緣起,以及為何選擇 C++ 作為轉譯目標。