工程

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 线时间轴的关系。

  • series — 值可以逐根变化,有历史:x[1] 表示上一根上的 x。涉及价格数据的表达式大多默认如此。
  • simple — 值在整个策略运行期间固定,没有可用的历史(对 simple 值访问 x[1] 在多数语境下是编译错误)。
  • const — 编译期已知,例如 const int VERSION = 6。可被代码生成折叠,不出现在最终二进制里。

形态限定符构成层级: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——看起来像实现细节,却决定哪些运算合法、哪些值能传给哪些函数、哪些表达式能在编译期求值而不是逐根求值。做对这套,才是“能用的转译器”和“好用的转译器”的分水岭。

接下来