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++ 作为转译目标。