El sistema de tipos de Pine v6, en la práctica
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 estar débilmente tipado. La fama es en parte merecida y en gran parte equivocada. Se ganó la etiqueta porque int se ensancha a float en silencio, na se acepta casi en cualquier parte y el lenguaje apenas exige anotaciones explícitas. Pero bajo esa superficie permisiva hay un sistema de tipos con dientes de verdad — y cuando necesitas transpilarlo a un lenguaje estático, cada diente importa.
Este artículo repasa qué son realmente los tipos de Pine v6, dónde la capa de inferencia oculta la complejidad y los tres escollos que más nos encontramos al construir el codegen en C++.
Reputación frente a realidad
Un programador nuevo de Pine escribe:
x = 5
y = close + xSin anotaciones. Sin quejas del compilador. Se concluye «Pine no tiene tipos». Pero Pine infirió exactamente qué son esas variables: x es un simple int (constante entera en tiempo de compilación), e y es un series float (valor en coma flotante por barra). La inferencia hace un trabajo real; solo que es invisible.
La distinción entre simple 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
Pine v6 tiene cuatro niveles en su sistema de tipos. La mayoría solo tropezará con el primero.
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 guardan valores escalares.
int y float participan en el ensanchamiento implícito: al mezclarlos en una expresión, el int se ensancha a float. Este comportamiento es lo que le vale a Pine la etiqueta de «débilmente tipado», pero es una regla deliberada y consistente, no un caso especial. Todo lenguaje fuertemente tipado 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 su tipo de elemento, que debe ser un tipo Pine (incluidos los UDT). Puedes tener array<float>, array<int>, incluso array<MyUDT>.
Los tipos compuestos son objetos mutables. Son tipos referencia en el sentido de que pasar un array<float> a una función le da a la función un manejador del mismo almacenamiento subyacente. Si la función modifica el array, quien llama ve el cambio.
Tipos definidos por el usuario (UDT)
Pine v6 añadió la palabra clave type, que permite definir tipos registro 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 := trueLos UDT son el análogo más cercano de Pine a los structs. Pueden tener valores por defecto en los campos (como filled = false). Los métodos se llaman con notación punto y reciben la instancia UDT como this. Los campos son mutables con la asignación :=.
Calificadores de forma: series frente a simple
Todo valor en Pine —no solo primitivos, también compuestos y 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 barra en barra. Tiene historia:x[1]te da el valor dexen la barra anterior. Es el valor por defecto en la mayoría de expresiones con datos de precio.simple— el valor es fijo durante toda la ejecución de la estrategia. No tiene historia útil (acceder ax[1]sobre un valorsimplees error de compilación en la mayoría de contextos).const— el valor se conoce en tiempo de compilación. Por ejemploconst int VERSION = 6. Lo elimina el codegen por constant folding; no aparece en la salida compilada.
Los calificadores forman una jerarquía: const ⊂ simple ⊂ series. Una expresión que mezcla formas se promociona a la más alta (la más general). simple int + series float es series float.
Semántica de na
na no es un null universal. Es un valor tipado — na<int> y na<float> son distintos, y el tipo se infiere del contexto. En una expresión como:
x = naPine infiere x como series float por defecto (el tipo más útil para una serie sin inicializar). En un contexto donde el tipo queda restringido —por ejemplo, si luego escribes x := 5— la inferencia fija int.
La aritmética sobre 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álvula de escape es nz(x, 0), que sustituye na por un valor por defecto.
Los operadores de comparación siguen la misma regla: na == na es na, no true. Este es el escollo más habitual. Si quieres comprobar si falta un valor, usa na(x) (el predicado integrado), no x == na.
En nuestro codegen C++, na se mapea a std::optional<T>. La aritmética sobre std::nullopt produce std::nullopt. El predicado na(x) pasa a ser !x.has_value(). Las semánticas encajan bien; en C++ hay más verbosidad.
Inferencia de tipos en detalle
Pine infiere tipos del lado derecho de las asignaciones. Las reglas son claras una vez entiendes 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 : 0condition puede ser series bool. close es series float. 0 es const int. Pine ensancha 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 función también restringen la inferencia. Si un built-in espera simple int y pasas bar_index (que es series int), obtienes error de compilación. Ahí sale a la luz la rigidez del sistema de tipos: no puedes pasar un valor que varía bar a bar a un parámetro que exige un valor fijado al arranque.
Cómo representa el codegen C++ estos tipos
Traducir el sistema de tipos de Pine a C++ exige mapear cada forma y tipo de Pine a una representación C++ con el comportamiento correcto en tiempo de ejecución.
Los primitivos se mapean a double, int64_t, bool y std::string. El float de Pine es siempre de 64 bits; usamos double en todas partes.
Las series se representan como buffers de historia evaluados de forma perezosa con plantillas. La abstracción expone:
template <typename T>
class Series {
public:
T current() const;
T at(int bars_ago) const; // implements the [] operator
void advance(T next_value);
};Cuando el código Pine escribe close[2], el codegen emite close.at(2). Al cerrarse una barra, el motor llama a advance() en cada serie para desplazar la ventana de historia.
na se mapea a std::optional<T>. Los operadores aritméticos sobre Series<std::optional<T>> propagan nullopt igual que Pine propaga na. El built-in nz() pasa a ser x.value_or(default_value).
Los UDT son struct simples. Los campos son miembros. Los métodos son funciones miembro. El operador de asignación de campo := de Pine es una asignación C++ normal; no hay setters.
Los arrays y matrices pasan a ser std::vector<T> y un envoltorio 2D fino sobre std::vector<T>, respectivamente.
Los tres escollos que más vimos
1. Mezclar series y simple promociona a series — incluso cuando esperabas una constante
myLength = 14
ma = ta.sma(close, myLength)Si myLength está declarado en ámbito global y nunca cambia, podrías esperar que el codegen lo trate como constante de compilación. Pine lo infiere como simple int, que no es const: el valor queda fijado al arranque en tiempo de ejecución, no en compilación. La consecuencia es que ta.sma(close, myLength) es series float, no algo que el compilador pueda plegar.
Esto importa en nuestro codegen porque queríamos hacer constant folding de ciertos tamaños de ventana por rendimiento. El sistema de tipos de Pine se niega con razón: simple no es const, y una variable declarada sin const podría en principio fijarse con input.int() — lo que la hace determinada al arranque, no en compilación.
2. Asignar un campo 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 notificationEn un lenguaje orientado a objetos podrías esperar que b.value := 20.0 pase por un setter que pudiera llamar a doubled() u otro comportamiento. En Pine no ocurre. Los campos son públicos y directamente mutables. Los métodos son solo funciones ligadas al tipo. Si construyes una máquina de estados en un UDT y quieres validación al escribir, debes llamar explícitamente a un método que valide — las escrituras de campo lo saltan todo.
Lo documentamos por dentro porque es donde un programa Pine puede parecer tener encapsulación y no la tiene. El codegen C++ lo refleja con precisión: las escrituras de campo son asignaciones simples a miembros de struct.
3. request.security es causal por defecto — pero el lookahead por defecto ha cambiado entre versiones
htf_close = request.security("BTCUSDT", "D", close)La llamada es correcta en tipos y compila sin avisos. Lo que devuelve es el cierre diario según el tiempo de la barra actual. Si la barra actual es de 15 minutos a mitad del día, htf_close lleva el cierre del día anterior hasta que cierra la nueva barra diaria — interpretación causal.
El escollo histórico: en Pine v4, request.security tenía por defecto lookahead=barmerge.lookahead_on, lo que permitía espiar la barra diaria futura antes de cerrar. Esto inflaba los backtests de estrategias con datos HTF. Pine v5 cambió el valor por defecto a lookahead_off (causal), que es el comportamiento correcto. Pine v6 mantiene el valor por defecto de v5.
El sistema de tipos no advierte porque ambos modos son type-correct. El codegen de PineForge aplica por defecto semántica lookahead_off, alineada con el comportamiento de referencia de Pine v6. Si portas una estrategia desde v4 y los resultados en PineForge sorprenden — suele ser por esto.
Por qué transpilar Pine nos volvió más rigurosos
Toda ambigüedad en las reglas de tipos de Pine debe convertirse en una decisión en el codegen C++. Cuando la documentación de referencia dice «esto depende de la implementación» o dos comportamientos encajan con la spec, no podemos elegir al azar. Hay que contrastar con la salida real de TradingView, averiguar qué rama tomó TradingView e implementar esa rama.
Eso nos obligó a leer la referencia de Pine v6 con más cuidado que un usuario medio. También generó la mayor parte de nuestros tests de paridad: cuando encontramos un caso límite en request.security o strategy.exit donde nuestro codegen discrepa de TradingView, escribimos un reproducción mínima, la añadimos al corpus validation y ajustamos 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 de implementación, pero determinan qué operaciones son legales, qué valores pueden pasarse a qué funciones y qué expresiones pueden evaluarse en compilación en lugar de bar a bar. Acertarlos es la diferencia entre un transpilador y uno bueno.
Qué hacer después
- Probar la API de codegen desde Claude o Cursor — transpila tus propias estrategias Pine v6 y ejecútalas con datos OHLCV locales. La herramienta
transpile_pinedevuelve la salida C++ si quieres inspeccionar las representaciones de tipos. - Explorar la galería — la categoría
validationincluye 142 estrategias pensadas para ejercitar casos límite del sistema de tipos. Los recuentos de operaciones indican qué estrategias generan suficiente actividad para que los errores se vean. - Leer por qué construimos esto — el origen del motor y la decisión de apuntar a C++ como destino de transpilación.