Saltar al contenido

El sistema de tipos de Pine v6, en la práctica

10 min de lectura

PineScript v6 está más tipado de lo que parece. Reglas de tipos, inferencia, trampas — y lo que aprendimos al shippear un transpilador sobre qué reglas importan de verdad en runtime.

Pine Script tiene fama de ser un lenguaje débilmente tipado. La fama es en parte merecida y en gran parte equivocada. Se ganó la etiqueta porque int se amplía a float en silencio, na se acepta casi en cualquier sitio y el lenguaje rara vez exige una anotación explícita. Pero bajo esa superficie permisiva hay un sistema de tipos con dientes de verdad, y en cuanto necesitas transpilarlo a un lenguaje con tipado estático, cada diente importa.

Este artículo repasa qué son realmente los tipos de Pine v6, dónde esconde la complejidad la capa de inferencia y tres trampas que importan en cuanto compilas Pine a C++.

Reputación frente a realidad

Alguien que empieza a programar en Pine escribe:

x = 5
y = close + x

Sin anotaciones. Sin quejas del compilador. Quien lo escribe concluye que «Pine no tiene tipos». Pero Pine infirió exactamente qué son esas variables: x es un const int (un literal, conocido en tiempo de compilación) e y es un series float (un valor de coma flotante por barra). La inferencia hace un trabajo real; simplemente no se ve.

La distinción entre const int y series float no es cosmética. Tienen comportamientos distintos en tiempo de ejecución, reglas distintas de compatibilidad de operadores y representaciones distintas en una salida transpilada. El lenguaje está tipado; simplemente no te obliga a escribir los tipos.

Las clases de tipos

Aquí nos centramos en cuatro capas del sistema de tipos de Pine v6. La mayoría de los usuarios solo se encuentra con la primera.

Primitivos

Los tipos primitivos son int, float, bool, string y color. También está na, que merece su propia sección más abajo. Son los tipos hoja: los que contienen valores escalares.

int y float participan en la ampliación implícita: cuando los mezclas en una expresión, el int se amplía a float. Es el comportamiento que le vale a Pine la etiqueta de «débilmente tipado», pero es una regla deliberada y coherente, no un caso especial. C++ hace lo mismo con los tipos numéricos.

Compuestos

Pine v6 admite tres tipos compuestos: array<T>, matrix<T> y map<K, V>. Los tres están parametrizados por sus tipos de elemento, que pueden ser UDT, salvo las claves de un mapa, que deben ser de un tipo de valor como int o string. Puedes tener array<float>, array<int> e incluso array<MyUDT>.

Los tipos compuestos son objetos mutables. Son tipos por referencia en el sentido de que, al pasar un array<float> a una función, esta recibe un handle al mismo almacenamiento subyacente. Si la función modifica el array, quien la llamó ve el cambio.

Tipos definidos por el usuario (UDT)

Pine v5 añadió la palabra clave type en diciembre de 2022 y la palabra clave method en febrero de 2023. Juntas permiten definir tipos registro con nombre, con campos y métodos opcionales:

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

Los UDT son lo más parecido a los structs que tiene Pine. Pueden tener valores por defecto en los campos (como se ve con filled = false). Los métodos se llaman con notación de punto y reciben la instancia del UDT como primer parámetro, que aquí se llama this. Los campos se pueden modificar con la asignación :=.

Calificadores de forma: series frente a simple

Todo valor en Pine (no solo los primitivos, también los compuestos y los UDT) lleva un calificador de forma que describe su relación con la línea temporal de las barras.

  • series: el valor puede cambiar de una barra a otra. Tiene historia: x[1] te da el valor de x en la barra anterior. Es lo habitual en la mayoría de las expresiones con datos de precio.
  • simple: el valor se fija en la primera barra y no cambia durante el resto de la ejecución, como syminfo.ticker.
  • input: el valor viene de la configuración del script a través de input.int() y sus funciones hermanas: fijo durante la ejecución y conocido cuando arranca el script.
  • const: el valor se conoce en tiempo de compilación: todo literal y cualquier cosa declarada como const int VERSION = 6. El codegen puede escribirlo directamente en el C++ (la primera trampa de más abajo muestra cómo).

Los calificadores forman una jerarquía, const < input < simple < series, y una expresión toma el calificador más fuerte de entre sus partes: simple int + series float es series float.

Semántica de na

na no es un null universal: Pine sigue necesitando saber de qué tipo es el valor que falta. Por eso esta línea no compila:

x = na

No hay nada de lo que inferir el tipo. Decláralo:

float x = na

La aritmética con na se propaga: na + 1 es na. Es intencional y coherente: si un valor es desconocido, cualquier expresión que dependa de él también lo es. La vía de escape es nz(x, 0), que sustituye na por un valor por defecto.

Las comparaciones se comportan de otra manera: ==, != y cualquier otra comparación dan false cuando uno de los operandos contiene na, y escribir el propio na como operando, como en x == na, no compila. Usa en su lugar el predicado integrado na(x).

En el C++ de PineForge, na es un valor centinela del propio tipo de la variable, y na(x) pasa a ser is_na(x). Las comparaciones generadas comprueban primero ambos lados con is_na, de modo que una comparación con na da false.

La inferencia de tipos en detalle

Pine infiere los tipos a partir del lado derecho de las asignaciones. Las reglas son sencillas una vez que conoces la jerarquía de calificadores de forma:

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)

El caso interesante es la inferencia entre ramas:

x = condition ? close : 0

condition puede ser series bool. close es series float. 0 es const int. Pine amplía 0 a series float y el resultado es series float. El compilador siempre elige la forma más general y el tipo más amplio.

Los argumentos de las funciones también restringen la inferencia. Si una función integrada espera simple int y le pasas bar_index (que es series int), obtienes un error de compilación. Aquí es donde asoma la rigidez del sistema de tipos: no puedes pasar un valor que cambia de barra en barra a un parámetro que exige un valor fijado al arrancar.

Cómo representa estos tipos el codegen de C++

Traducir el sistema de tipos de Pine a C++ exige asignar a cada forma y cada tipo de Pine una representación en C++ con el comportamiento correcto en tiempo de ejecución.

Los detalles siguientes corresponden a las cabeceras del motor v1.0.0 y al C++ que genera el codegen 1.0.0.

Los primitivos se convierten en escalares de C++: double para float, int o int64_t para int (algunos valores int calculados, entre ellos bar_index y hour, se guardan en un double), bool para bool, std::string para string e int64_t para color.

Las series son búferes circulares con el historial. Esta es la clase del motor, de 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;
};

Cuando el código Pine escribe close[2], el C++ generado lee _s_close[2].

na es un centinela del propio tipo del valor (include/pineforge/na.hpp): na<double>() es un NaN silencioso, na<int>() es el int más pequeño y na<bool>() es false. is_na(x) lo detecta, y nz(x, y) se convierte en un is_na(x) ? y : x en línea.

Los UDT se convierten en handles: el struct generado contiene un id, los campos viven en registros que pertenecen a la estrategia (para que un checkpoint pueda revertirlos) y cada método se convierte en una función que recibe el handle, por ejemplo _udt_Acc_add(Acc self, double v). Escribir en un campo es una asignación al campo del registro; no hay setters.

Los arrays se convierten en std::vector<T>. Las matrices usan la clase NumericMatrix del motor (sobre MatrixXd de Eigen) para matrix<float> y GenericMatrix<T> para cualquier otro tipo de elemento, matrix<int> incluido; el código generado las sigue llamando PineMatrix y PineGenericMatrix<T>, alias que el motor v1.0.0 conserva.

Tres trampas

1. Un valor que nunca cambia no es necesariamente const

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

Ninguna de las dos longitudes cambia durante una ejecución, pero solo una se conoce en tiempo de compilación. atrLen es const int: un literal es const, y una variable inicializada con uno hereda el calificador. fastLength es input int: su valor viene de la configuración del script cuando el script arranca.

El C++ generado traza la misma línea. En el corpus abierto, atrLen = 14 seguido de ta.atr(atrLen) se compila a ta::ATR(14), con el 14 escrito en el código. fastLength = input.int(22, "Fast EMA Length") se compila a get_input_int("Fast EMA Length", 22), que se lee cuando arranca el backtest. Eso es lo que permite ejecutar una misma estrategia generada con distintos valores de entrada: la herramienta backtest_pine_grid del servidor MCP local transpila el Pine una sola vez y ejecuta el mismo C++ con cada combinación de valores de entrada.

2. Asignar un campo de un UDT no dispara métodos, y es intencional

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

En un lenguaje orientado a objetos, podrías esperar que b.value := 20.0 pasara por un setter capaz de llamar a doubled() o de desencadenar cualquier otro comportamiento. En Pine no ocurre. Los campos son públicos y se pueden modificar directamente. Los métodos son solo funciones asociadas al tipo. Si estás construyendo una máquina de estados en un UDT y quieres validar al escribir, tienes que llamar explícitamente a un método que haga la validación: las escrituras de campos se lo saltan todo.

Es el principal lugar donde un programa Pine puede parecer que tiene encapsulación sin tenerla. El C++ generado lo refleja: escribir en un campo es una asignación al campo del registro, sin ningún método de por medio.

3. request.security es causal por defecto, pero el valor por defecto de lookahead ha cambiado entre versiones

htf_close = request.security(syminfo.tickerid, "D", close)

Esta llamada es correcta en cuanto a tipos y compila sin avisos. Lo que devuelve es el cierre diario a la hora de la barra actual. Si la barra actual es una barra de 15 minutos a mitad del día, htf_close contiene el cierre del día anterior hasta que cierra la nueva barra diaria: esa es la interpretación causal.

La trampa histórica: en Pine v2, security tenía por defecto lookahead=barmerge.lookahead_on, lo que permitía asomarse a la barra diaria futura antes de que cerrara. Eso inflaba los resultados de los backtests de estrategias que usaban datos HTF. Pine v3 cambió el valor por defecto a lookahead_off (causal), que es el comportamiento correcto, y todas las versiones posteriores, v6 incluida, lo mantienen.

El sistema de tipos no te avisa de esto porque ambos modos son correctos en cuanto a tipos. Sin un argumento lookahead, el codegen de PineForge aplica la semántica de lookahead_off, igual que el valor por defecto de Pine v6.

Por qué transpilar Pine nos hizo más rigurosos

Toda ambigüedad en las reglas de tipos de Pine tiene que convertirse en una decisión en el codegen de C++. Cuando la documentación de referencia de Pine dice «esto depende de la implementación», o cuando dos comportamientos son técnicamente coherentes con la especificación, no podemos elegir uno y cruzar los dedos. Tenemos que probar contra la salida real de TradingView, averiguar qué rama tomó TradingView e implementar esa rama.

Este proceso nos ha obligado a leer la referencia del lenguaje Pine v6 con más cuidado del que se tomaría cualquier usuario. También dio lugar a muchas de las estrategias del corpus abierto: cuando encontramos un caso límite en request.security o strategy.exit en el que nuestro codegen discrepa de TradingView, escribimos un caso mínimo que lo reproduce, lo añadimos al corpus y corregimos el codegen hasta que ambas salidas coinciden.

El sistema de tipos es donde viven los casos límite. Los calificadores de forma (series frente a simple frente a const) parecen un detalle menor de implementación, pero determinan qué operaciones son legales, qué valores se pueden pasar a qué funciones y qué expresiones se pueden evaluar en tiempo de compilación en lugar de barra a barra. Acertar con ellos es la diferencia entre un transpilador y uno bueno.

Por dónde seguir

  • Transpila desde Claude o Cursor: la herramienta transpile_pine del servidor MCP local devuelve el C++ si quieres inspeccionar estas representaciones.
  • Explora la galería: cada una de las 312 estrategias del corpus abierto muestra su código Pine y el C++ generado a partir de él.
  • Lee por qué construimos esto: la historia del origen del motor y de la decisión de elegir C++ como destino de la transpilación.