PineScript v6 比你想象的更强类型。走一遍规则与推导、常见坑——以及我们做 transpiler 后学到的:哪些规则在运行时才真正要紧。
Pine Script 素有「弱类型」之名。这个名声有几分道理,但大体是误解。它之所以落下这个名声,是因为 int 会悄无声息地拓宽为 float,na 几乎在任何地方都能用,而且语言很少要求显式的类型注解。但在这层宽松的表面之下,是一套真正有约束力的类型系统——而一旦要把它转译成静态类型语言,每一条约束都不容忽视。
本文梳理 Pine v6 的类型究竟是什么、类型推断层把复杂性藏在了哪里,以及一旦把 Pine 编译成 C++,就必须留意的三个坑。
名声与现实
一位 Pine 新手写下:
x = 5
y = close + x
没有类型注解,编译器也不抱怨。于是这位程序员得出结论:「Pine 没有类型。」但 Pine 其实精确地推断出了这两个变量是什么:x 是 const int(字面量,编译期已知),y 是 series float(逐根 K 线变化的浮点值)。推断在实打实地工作,只是你看不见。
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——都带有一个形态限定符(form qualifier),描述它与 K 线时间轴的关系。
series——值可以逐根 K 线变化,并且有历史:x[1]给出x在上一根 K 线上的值。大多数涉及价格数据的表达式默认都是这种形态。simple——值在第一根 K 线上确定,此后在整个运行期间保持不变,例如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;而把 na 本身直接写成操作数,比如 x == 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),就会得到一个编译错误。类型系统的严格之处正体现在这里:你不能把一个逐根 K 线变化的值,传给要求启动时就固定的参数。
C++ 代码生成如何表示这些类型
要把 Pine 的类型系统翻译成 C++,就需要把每一种 Pine 形态和类型,映射到具有正确运行时行为的 C++ 表示上。
以下细节与引擎 v1.0.0 的头文件,以及 codegen 1.0.0 生成的 C++ 代码一致。
基本类型映射为 C++ 标量:float 对应 double,int 对应 int 或 int64_t(部分计算得到的 int 值会存成 double,bar_index 和 hour 就在其中),bool 对应 bool,string 对应 std::string,color 则对应 int64_t。
Series 是保存历史值的环形缓冲区。下面是引擎中的这个类,出自 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)。字段写入就是对记录字段的赋值,没有 setter。
数组变成 std::vector<T>。矩阵方面,matrix<float> 使用引擎的 NumericMatrix(基于 Eigen 的 MatrixXd),其他所有元素类型都使用 GenericMatrix<T>,matrix<int> 也不例外;生成的代码里仍写作 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 转译一次,再用同一份 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 会经过一个 setter,进而调用 doubled() 或触发其他行为。在 Pine 里并不会。字段是公开的,可以直接修改;方法只是作用域限定在该类型上的函数。如果你在 UDT 里实现状态机,并且希望写入时做校验,就必须显式调用负责校验的方法——字段写入会绕过一切。
这是 Pine 程序看似有封装、实则没有的主要地方。生成的 C++ 体现了这一点:字段写入就是对记录字段的赋值,中间不经过任何方法。
3. request.security 默认是因果的——但 lookahead 的默认值在不同版本间变过
htf_close = request.security(syminfo.tickerid, "D", close)
这个调用类型正确,编译时也没有任何警告。它返回的是截至当前 K 线时刻的日线收盘价。如果当前是盘中的一根 15 分钟 K 线,那么在新的日线收盘之前,htf_close 一直沿用前一天的收盘价——这就是因果解释。
历史遗留的坑是:在 Pine v2 中,security 默认使用 lookahead=barmerge.lookahead_on,允许在未来的日线收盘之前就偷看它。这让使用 HTF(高周期)数据的策略回测成绩虚高。Pine v3 把默认值改成了 lookahead_off(因果),这才是正确的行为;此后的每个版本,包括 v6,都沿用了这一默认值。
类型系统不会就此提醒你,因为两种模式在类型上都正确。在没有 lookahead 参数时,PineForge 代码生成采用 lookahead_off 语义,与 Pine v6 的默认行为一致。
为什么转译 Pine 让我们更严谨
Pine 类型规则中的每一处歧义,都必须在 C++ 代码生成里落实为一个明确的决定。当 Pine 参考文档写着「由实现定义」,或者两种行为在技术上都符合规范时,我们不能随便挑一个碰运气。我们必须对照 TradingView 的实际输出测试,弄清 TradingView 走的是哪条分支,然后实现那条分支。
这个过程让我们把 Pine v6 语言参考读得比任何用户都仔细。公开语料库中的许多策略也由此而来:每当发现在 request.security 或 strategy.exit 的某个边角情况下,我们的代码生成与 TradingView 不一致,就写一个最小复现,加进语料库,然后修复代码生成,直到两边输出一致。
类型系统是边角情况的聚集地。形态限定符——series、simple 与 const——看起来只是细枝末节的实现细节,却决定了哪些运算合法、哪些值能传给哪些函数、哪些表达式能在编译期求值而不必逐根 K 线计算。能否把它们处理对,决定了你做出的只是一个转译器,还是一个好的转译器。
接下来
- 在 Claude 或 Cursor 中转译:如果你想查看这些表示,本地 MCP 服务器的
transpile_pine工具会返回 C++ 代码。 - 浏览策略画廊:公开语料库的 312 个策略,每一个都展示 Pine 源码以及由它生成的 C++。
- 了解我们为什么要做这件事——引擎的缘起,以及为什么选择 C++ 作为转译目标。