本文へスキップ

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

読了 約10分

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

Pine Script には、型付けが緩い言語だという評判がある。その評判は一部は当たっているが、大部分は間違っている。int が黙って float にワイドニングされること、na がほぼどこでも受け入れられること、そして明示的な型注釈がめったに求められないことから、そう呼ばれるようになった。しかし、その寛容な表面の下には、しっかりと牙を持つ型システムがある。静的型付け言語へトランスパイルする必要が出てくると、その牙の一本一本が効いてくる。

この記事では、Pine v6 の型が実際にどういうものか、推論の層がどこに複雑さを隠しているか、そして Pine を C++ にコンパイルするようになると効いてくる三つの落とし穴を見ていく。

評判と現実

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

x = 5
y = close + x

型注釈はない。コンパイラも何も言わない。そこで「Pine には型がない」と結論づける。しかし Pine は、これらの変数が何であるかを正確に推論している。x は const int(リテラルで、コンパイル時に分かる値)、y は series float(バーごとの浮動小数点値)だ。推論はきちんと仕事をしている。ただ目に見えないだけだ。

const int と series float の違いは、見た目だけの話ではない。実行時の振る舞いも、演算子の互換性の規則も、トランスパイル後の出力での表現も異なる。言語には型がある。ただ、型を書くことを強制しないだけだ。

型の種類

ここでは、Pine v6 の型システムを四つの層に分けて見ていく。ほとんどのユーザーが出会うのは、最初の層だけだ。

プリミティブ

プリミティブ型は int、float、bool、string、color だ。ほかに na もあるが、これは後の節で改めて扱う。これらはスカラー値を保持する、末端の型だ。

int と float は暗黙のワイドニングの対象になる。式の中で両者を混ぜると、int が float に広げられる。Pine が「緩い型付け」と呼ばれる原因になっている挙動だが、これは特例ではなく、意図的で一貫した規則だ。C++ も、数値型では同じことをしている。

複合型

Pine v6 は三つの複合型をサポートしている。array<T>、matrix<T>、map<K, V> だ。三つとも要素型でパラメータ化されていて、要素型には UDT も使える。ただし、map のキーは int や string のような値型でなければならない。array<float>、array<int>、さらには array<MyUDT> も作れる。

複合型はミュータブルなオブジェクトだ。array<float> を関数に渡すと、関数は同じ下層ストレージへのハンドルを受け取る、という意味で参照型でもある。関数が配列を変更すれば、呼び出し元からもその変更が見える。

ユーザー定義型(UDT)

Pine v5 で、2022年12月に type キーワードが、2023年2月に method キーワードが加わった。この二つを組み合わせると、フィールドと、必要ならメソッドを持つ名前付きのレコード型を定義できる。

type Order
    float price
    int   qty
    bool  filled = false
 
method fill(Order this, float fillPrice) =>
    this.price  := fillPrice
    this.filled := true

UDT は、Pine で構造体に最も近いものだ。フィールドにはデフォルト値を持たせられる(filled = false のように)。メソッドはドット記法で呼び出し、UDT のインスタンスを第一引数として受け取る。この例では、その引数に this という名前を付けている。フィールドは := による代入で変更できる。

形の修飾子:series と simple

Pine のすべての値は、プリミティブだけでなく複合型や UDT も含め、バーのタイムラインとの関係を表す形の修飾子を持つ。

  • series — 値はバーごとに変わりうる。履歴を持ち、x[1] で前のバーでの x の値が得られる。価格データを含む式の大半では、これがデフォルトだ。
  • simple — 値は最初のバーで決まり、その後の実行中は固定される。syminfo.ticker がその例だ。
  • input — 値は input.int() などの関数を通じて、スクリプトの設定から来る。実行中は固定で、スクリプトの開始時に分かる。
  • const — 値はコンパイル時に分かる。すべてのリテラルと、const int VERSION = 6 のように宣言したものがこれに当たる。コード生成はこの値を C++ に直接書き込める(その様子は後述の一つ目の落とし穴で示す)。

修飾子は const < input < simple < series という階層をなし、式はその構成要素のうち最も強い修飾子を取る。simple int + series float は series float だ。

na の意味論

na は万能の null ではない。Pine は、どの型の値が欠けているのかを知る必要がある。そのため、次の行はコンパイルできない。

x = na

型を推論する手がかりが何もないからだ。代わりに型を宣言する。

float x = na

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

比較の振る舞いは違う。== や != をはじめ、どの比較も、オペランドが na を保持していれば false になる。また、x == na のように na そのものをオペランドとして書くと、コンパイルできない。代わりに、組み込みの述語 na(x) を使う。

PineForge の C++ では、na はその変数自身の型の番兵値で、na(x) は is_na(x) になる。生成される比較はまず両辺を is_na で確認するので、na との比較は false になる。

型推論の細部

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 bool かもしれない。close は series float だ。0 は const int だ。Pine は 0 を series float にワイドニングし、結果は series float になる。コンパイラは常に、最も一般的な形と、最も包括的な型を選ぶ。

関数の引数も推論を制約する。組み込み関数が simple int を期待しているところに bar_index(series int)を渡すと、コンパイルエラーになる。型システムの厳しさが表に出るのはここだ。バーごとに変わる値を、起動時に固定されている必要のあるパラメータには渡せない。

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

Pine の型システムを C++ に翻訳するには、Pine の形と型のそれぞれを、実行時に正しく振る舞う C++ の表現に対応づける必要がある。

以下の内容は、エンジン v1.0.0 のヘッダーと、codegen 1.0.0 が生成する C++ に基づいている。

プリミティブ は C++ のスカラーに対応する。float には double、int には int または int64_t(ただし、bar_index や hour など、計算で得られる int 値の一部は double として保持される)、bool には bool、string には std::string、color には int64_t だ。

シリーズ は履歴を保持するリングバッファだ。エンジンのクラスは次のとおり(include/pineforge/series.hpp より)。

template <typename T>
class Series {
public:
    void push(T value);             // a new bar
    void update(T value);           // the same bar again (bar magnifier)
    T operator[](int offset) const; // offset bars back; na if out of range
    T current() const;
};

Pine のコードで close[2] と書くと、生成された C++ では _s_close[2] を読む。

na は、その値自身の型の番兵値だ(include/pineforge/na.hpp)。na<double>() は quiet NaN、na<int>() は int の最小値、na<bool>() は false になる。is_na(x) でこれを判定し、nz(x, y) はインラインの is_na(x) ? y : x になる。

UDT はハンドルになる。生成される struct は ID を保持し、フィールドはストラテジーが所有するレコードに置かれる(そのため、チェックポイントでロールバックできる)。各メソッドは、たとえば _udt_Acc_add(Acc self, double v) のように、ハンドルを受け取る関数になる。フィールドへの書き込みはレコードのフィールドへの代入であり、セッターはない。

配列 は std::vector<T> になる。行列 は、matrix<float> ならエンジンの NumericMatrix(Eigen の MatrixXd がベース)を使い、matrix<int> を含むそれ以外の要素型ではすべて GenericMatrix<T> を使う。生成されるコードでは今も PineMatrix と PineGenericMatrix<T> という名前が使われているが、これらはエンジン v1.0.0 が残しているエイリアスだ。

三つの落とし穴

1. 変わらない値が const とは限らない

atrLen     = 14
fastLength = input.int(22, "Fast EMA Length")
atr  = ta.atr(atrLen)
fast = ta.ema(close, fastLength)

どちらの期間も実行中に変わることはないが、コンパイル時に値が分かるのは片方だけだ。atrLen は const int だ。リテラルは const であり、リテラルで初期化した変数はその修飾子を引き継ぐ。fastLength は input int で、その値はスクリプトの開始時に設定から読み込まれる。

生成される C++ も、同じところで線を引いている。公開コーパスでは、atrLen = 14 に続く ta.atr(atrLen) は ta::ATR(14) にコンパイルされ、14 がコードに直接書き込まれる。fastLength = input.int(22, "Fast EMA Length") は get_input_int("Fast EMA Length", 22) にコンパイルされ、バックテストの開始時に読み込まれる。一度生成した戦略を、異なる入力で実行できるのはこのおかげだ。ローカル MCP サーバーの backtest_pine_grid は、Pine を 1 回だけトランスパイルし、入力値のすべての組み合わせを同じ C++ で実行する。

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(syminfo.tickerid, "D", close)

この呼び出しは型として正しく、警告なしでコンパイルできる。返ってくるのは、現在のバーの時刻時点での日足の終値だ。現在のバーが日中の 15 分足なら、htf_close は新しい日足が確定するまで前日の終値を持ち続ける。これが因果的な解釈だ。

歴史的な落とし穴として、Pine v2 では security のデフォルトが lookahead=barmerge.lookahead_on で、確定前の未来の日足を覗き見できてしまった。そのため、上位足(HTF)のデータを使う戦略のバックテスト結果が水増しされていた。Pine v3 でデフォルトが lookahead_off(因果的)に変わり、これが正しい挙動だ。v6 を含め、それ以降のすべてのバージョンがこのデフォルトを維持している。

どちらのモードも型としては正しいので、型システムはこれについて警告しない。lookahead 引数がなければ、PineForge のコード生成は、Pine v6 のデフォルトに合わせて lookahead_off の意味論を適用する。

Pine をトランスパイルすることが私たちをより厳密にした理由

Pine の型規則にある曖昧さは、すべて C++ コード生成での判断に変えなければならない。Pine のリファレンスドキュメントに「実装依存」と書かれているときや、二つの振る舞いがどちらも仕様と技術的に矛盾しないとき、一方を選んで祈るわけにはいかない。TradingView の実際の出力と照らし合わせてテストし、TradingView がどちらの分岐を取ったのかを突き止め、その分岐を実装する必要がある。

この過程で、普通のユーザーならそこまではしないほど丁寧に、Pine v6 の言語リファレンスを読むようになった。公開コーパスの戦略の多くも、この過程から生まれた。request.security や strategy.exit のコーナーケースで、コード生成が TradingView と食い違うのを見つけたら、最小限の再現を書いてコーパスに加え、両方の出力が一致するまでコード生成を直す。

型システムは、コーナーケースが潜む場所だ。形の修飾子(series、simple、const)は実装上の細かい話に見えるが、どの操作が合法か、どの値をどの関数に渡せるか、どの式をバーごとではなくコンパイル時に評価できるかを決めている。これを正しく扱えるかどうかが、単なるトランスパイラと、良いトランスパイラの違いだ。

次に読むもの