pineforge
開始使用
工程

Pine v6 的型別系統:實務筆記

PineScript v6 比你想的更強型別。走一遍規則與推導、常見坑——以及我們做 transpiler 後學到的:哪些規則在執行期才真正重要。

約 10 分鐘閱讀#pine-script#types#language

Pine Script 常被說成弱型別。這種說法有一半屬實,多半不對。之所以貼上這個標籤,是因為 int 會無聲地拓寬到 floatna 幾乎隨處可用,而且語言很少要求你寫明示註解。但在這層寬鬆表象之下,是一套真正有約束力的型別系統——而一旦你要把它編譯到靜態型別語言裡,每一處細節都會較勁。

本文梳理 Pine v6 的型別到底是什麼、推論層如何把複雜度藏起來,以及我們在搭建 C++ 程式碼產生時最常踩的三個坑。

名聲與現實

新手會寫:

x = 5
y = close + x

沒有註解,編譯器也不吭聲,於是得出結論:「Pine 沒有型別。」實際上 Pine 精確推論出了變數意義:xsimple int(編譯期常數整數),yseries float(逐根 K 線的浮點值)。推論在做實事,只是看不見而已。

simple intseries float 的差別並非擺設:執行行為、運算子相容性,以及轉譯輸出裡的表示都不同。語言是有型別的;它只是不強迫你把型別寫出來。

型別的層次

Pine v6 的型別體系有四層。多數人只會碰到第一層。

原始型別

原始型別包括 intfloatboolstringcolor。此外還有下文單獨討論的 na。它們是存放純量值的葉子型別。

intfloat 參與隱式拓寬:在同一運算式裡混用時,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 := true

UDT 最接近結構體。欄位可有預設值(如 filled = false)。方法用點號呼叫,實例作為 this 傳入。欄位可用 := 變更。

形態限定詞:series 與 simple

Pine 中的每一個值——不僅是原始型別,也包括複合型別與 UDT——都帶有一個 形態限定詞,描述它與 K 線時間軸的關係。

形態限定詞構成層級:constsimpleseries。混合形態的運算式會升到最高(最一般)的形態。simple int + series float 的結果是 series float

na 的語意

na 不是萬用的 null。它是帶型別的值——na<int>na<float> 不同,具體型別由上下文推論。例如:

x = na

Pine 預設把 x 推論為 series float(對未初始化序列最常用的預設)。若在受限上下文——例如之後又寫 x := 5——推論會鎖定為 int

na 做算術會傳播:na + 1 仍是 na。這是有意且一致的:值未知,則依賴它的運算式也未知。退路是 nz(x, 0),用預設值取代 na

比較運算子同理:na == nana,不是 true。這是最常見的誤區。要檢驗是否缺值,用內建述詞 na(x),不要用 x == na

在我們的 C++ 程式碼產生裡,na 對應為 std::optional<T>。對 std::nullopt 做算術得到 std::nulloptna(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 : 0

condition 可能是 series boolcloseseries float0const int。Pine 把 0 拓寬到 series float,結果為 series float。編譯器永遠取最一般的形態與最兜底的型別。

函式引數也會約束推論。若內建函式要求 simple int,你卻傳入 bar_indexseries int),就會編譯失敗。這時型別系統的嚴厲才浮出檯面:不能把逐根變化的值傳給要求在啟動時固定的參數。

C++ 程式碼產生如何表示這些型別

把 Pine 的型別體系譯成 C++,需要把每種形態與每種 Pine 型別對應到執行行為正確的 C++ 表示。

原始型別對應 doubleint64_tboolstd::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.securitystrategy.exit 的邊角上程式碼產生與 TradingView 不一致,就寫最小重現,加入 validation 語料,修到兩邊輸出對齊為止。

型別系統是邊角案例的棲地。形態限定詞——seriessimpleconst——看起來像實作細節,卻決定哪些運算合法、哪些值能傳給哪些函式、哪些運算式能在編譯期求值而不是逐根求值。做對這套,才是「能用的轉譯器」和「好用的轉譯器」的分水嶺。

接下來