エンジニアリング

実務で見る Pine v6 の型システム

PineScript v6 は見かけ以上に強く型付けされています。型規則、推論、落とし穴——トランスパイラを送り出して分かった、ランタイムで本当に効くルールはどれか。

読了 約10分#pine-script#types#language

Pine Script は緩い型付けだという評判がある。その評判は一部は当たっているが、ほとんどは誤りだ。int が静かに float にワイドニングされること、na がほぼどこでも受け入れられること、そして言語が明示的な注釈をほとんど要求しないことから、そのレッテルが貼られた。その許容的な表面の下には、実際に噛みつく型システムがある——静的型付け言語へトランスパイルする必要が出たとき、一本一本の歯が効いてくる。

この記事では Pine v6 の型が実際に何であるか、推論レイヤーが複雑さをどこに隠しているか、そして C++ コード生成を組み立てる過程で最も頻繁に踏んだ三つの落とし穴を順に見ていく。

評判と現実

Pine を始めたばかりの人が次のように書く。

x = 5
y = close + x

注釈なし。コンパイラの文句もなし。「Pine には型がない」と結論しがちだ。しかし Pine はそれらの変数が何であるかを正確に推論している:xsimple int(コンパイル時定数の整数)、yseries float(バーごとの浮動小数点値)だ。推論は本物の仕事をしているが、見えないだけである。

simple intseries float の違いは飾りではない。実行時の振る舞い、演算子の互換規則、トランスパイル結果での表現が異なる。言語は型付けされている。単に型を書かせないだけだ。

型の種類

Pine v6 の型システムには四つの層がある。ほとんどのユーザーが遭遇するのは最初の層だけだ。

プリミティブ

プリミティブ型は intfloatboolstringcolor である。ほかに後述の na がある。これらはスカラー値を保持する葉の型だ。

intfloat は暗黙のワイドニングに参加する。式の中で混ぜると intfloat に広がる。この挙動が Pine に「緩い型付け」というラベルを貼らせるが、それは特例ではなく意図的で一貫した規則だ。強く型付けされた言語であれば数値型について同様のことをする。

複合型

Pine v6 は三つの複合型をサポートする:array<T>matrix<T>map<K, V>。いずれも要素型でパラメタ化され、その要素型は UDT を含む Pine の型でなければならない。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 も——は、バーのタイムラインとの関係を表す 形の修飾子 を持つ。

  • series — 値はバーごとに異なりうる。履歴がある:x[1] は前バーの x を返す。価格データを含む式ではこれがデフォルトになりやすい。
  • simple — 値は戦略実行全体で固定される。履歴がない(simple 値に対して x[1] にアクセスするのはほとんどの文脈でコンパイルエラー)。
  • const — 値はコンパイル時に分かる。例:const int VERSION = 6。コード生成で畳まれ、コンパイル結果には現れない。

形の修飾子は階層をなす:constsimpleseries。異なる形が混ざる式は最も高い(最も一般的な)形へ昇格する。simple int + series floatseries float だ。

na の意味論

na は万能の null ではない。型付きの値であり、na<int>na<float> は別物で、型は文脈から推論される。例えば次の式では:

x = na

Pine は既定で xseries float と推論する(未初期化シリーズにとって最もよく使われる型)。型が後から拘束される文脈——たとえば後から x := 5 と書く——では、推論は int に固定される。

na に対する算術は伝播する:na + 1na だ。これは意図的で一貫している——値が未知なら、それに依存する式も未知になる。逃げ道は nz(x, 0) で、na をデフォルト値で置き換える。

比較演算子も同じ規則に従う:na == natrue ではなく na だ。これが最もよく刺さる落とし穴である。欠損かどうかを試すには na(x)(組み込み述語)を使い、x == na は使わない。

C++ コード生成では、nastd::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 : 0

conditionseries bool かもしれない。closeseries float0const int。Pine は 0series float にワイドニングし、結果は series float になる。コンパイラは常に最も一般的な形と、最も広い型を選ぶ。

関数の引数も推論を拘束する。ビルトインが simple int を期待しているのに bar_indexseries int)を渡せばコンパイルエラーになる。ここで型システムの厳しさが表れる——バーごとに変わる値を、起動時に固定されるべきパラメータには渡せない。

C++ コード生成での型の表現

Pine の型システムを C++ に翻訳するには、各 Pine の形と型を、実行時の振る舞いが正しい C++ 表現へ写す必要がある。

プリミティブdoubleint64_tboolstd::string に対応する。Pine の float は常に 64 ビットであり、全体で double を使う。

シリーズ はテンプレート化された遅延評価の履歴バッファとして表現される。抽象は次を公開する。

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) を出力する。バーが完了すると、エンジンは各シリーズに対して advance() を呼び履歴ウィンドウをずらす。

nastd::optional<T> に対応する。Series<std::optional<T>> 上の算術演算子は、Pine が na を伝播するのと同様に nullopt を伝播する。ビルトイン nz()x.value_or(default_value) になる。

UDT は素朴な struct になる。フィールドはメンバ、メソッドはメンバ関数。Pine のフィールド代入演算子 := は普通の C++ 代入であり、セッターはない。

配列と行列 はそれぞれ std::vector<T> と、std::vector<T> を薄く包んだ 2 次元ラッパになる。

最も踏んだ三つの落とし穴

1. series と simple を混ぜると series に昇格する——定数だと思ったときでも

myLength = 14
ma = ta.sma(close, myLength)

myLength がグローバルに宣言され一度も変わらないなら、コード生成がコンパイル時定数として扱うと期待しがちだ。Pine はそれを simple int と推論するが、それは const ではない——値はコンパイル時ではなく実行開始時に固定される。その結果 ta.sma(close, myLength)series float になり、コンパイラが畳めるものではない。

コード生成では特定のウィンドウサイズを性能のために定数畳み込みしたかったが、Pine の型システムは正しく拒否する:simpleconst ではなく、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 がセッターを経て doubled() などを呼ぶかもしれない。Pine ではそうならない。フィールドは公開され直接変更できる。メソッドは型にスコープされた関数に過ぎない。UDT で状態機械を組み、書き込み時に検証したいなら、検証を行うメソッドを明示的に呼ぶ必要がある——フィールドへの代入はすべてをバイパスする。

これは Pine がカプセル化があるように見えてない場所として内部で文書化している。C++ コード生成も忠実に反映する:フィールド書き込みは単なる構造体メンバ代入だ。

3. request.security は既定で因果的だが——lookahead の既定はバージョンで変わった

htf_close = request.security("BTCUSDT", "D", close)

この呼び出しは型的に正しく、警告なしでコンパイルする。返すのは 現在バーの時刻における 日足終値だ。現在バーが日中の 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 の実際の出力と突き合わせ、TradingView がどちらを選んだかを突き止め、その枝を実装する必要がある。

この過程で Pine v6 言語リファレンスを普通のユーザー以上に丁寧に読むようになった。同時にほとんどのパリティテストケースも生まれた:request.securitystrategy.exit の隅でコード生成が TradingView と食い違うと、最小再現を書いて validation コーパスに入れ、両者の出力が一致するまでコード生成を直す。

型システムこそが隅々に問題が潜む場所だ。形の修飾子——seriessimpleconst——は実装の細部に見えるが、どの操作が合法か、どの値をどの関数に渡せるか、どの式がバーごとではなくコンパイル時に評価できるかを決める。これを正しく扱えるかが、単なるトランスパイラと良いトランスパイラの差になる。

次に読むもの

  • Claude または Cursor からコード生成 API を試す — 自分の Pine v6 戦略をトランスパイルし、ローカルの OHLCV で実行する。型表現を確認したければ transpile_pine が C++ 出力を返す。
  • ギャラリーを見るvalidation カテゴリには型システムの境界を鍛えるために用意された 142 の戦略がある。トレード件数は、どの戦略ならエラーが見えやすいかの目安になる。
  • なぜこれを作ったかを読む — エンジンの由来と、トランスパイル先として C++ を選んだ理由。