Ingénierie

Le système de types de Pine v6, dans la pratique

PineScript v6 est plus typé qu’on ne le croit. Règles d’inférence, pièges — et ce qu’on a appris en livrant un transpileur sur ce qui compte vraiment au runtime.

10 min de lecture#pine-script#types#language

Pine Script traîne une réputation de langage « peu typé ». Cette réputation est en partie méritée, mais surtout fausse. On lui colle l’étiquette parce que int se promeut silencieusement vers float, que na est accepté presque partout, et que le langage exige rarement une annotation explicite. Sous cette surface permissive, il y a pourtant un système de types avec de vraies dents — et dès qu’il faut le transpiler vers un langage statique, chaque dent compte.

Ce billet décrit ce que sont réellement les types Pine v6, où la couche d’inférence dissimule la complexité, et les trois pièges que nous avons le plus souvent rencontrés en construisant le codegen C++.

Réputation contre réalité

Un nouveau programmeur Pine écrit :

x = 5
y = close + x

Pas d’annotations. Pas de plaintes du compilateur. On en conclut souvent « Pine n’a pas de types ». En réalité Pine infère précisément ce que sont ces variables : x est un simple int (un entier constant à la compilation), et y est un series float (une valeur flottante propre à chaque bougie). L’inférence fait un travail réel ; elle est simplement invisible.

La différence entre simple int et series float n’est pas cosmétique : comportements à l’exécution, compatibilité des opérateurs et représentation dans une sortie transpilée changent. Le langage est typé ; il ne vous oblige tout simplement pas à écrire les types.

Les familles de types

Pine v6 empile quatre niveaux dans son système de types. La plupart des utilisateurs ne croisent que le premier.

Primitifs

Les types primitifs sont int, float, bool, string et color. Il y a aussi na, qui mérite une section à part ci-dessous. Ce sont les types feuilles — ceux qui portent des valeurs scalaires.

int et float participent à l’élargissement implicite : lorsqu’on les mélange dans une expression, int monte en float. C’est ce comportement qui nourrit l’étiquette « peu typé », mais c’est une règle délibérée et cohérente, pas un cas particulier. Tout langage fortement typé fait pareil pour les types numériques.

Composites

Pine v6 prend en charge trois types composites : array<T>, matrix<T> et map<K, V>. Les trois sont paramétrés par leur type d’élément, qui doit être un type Pine (UDT inclus). On peut avoir array<float>, array<int>, même array<MyUDT>.

Les types composites sont des objets mutables. Au sens références : passer un array<float> à une fonction donne à la fonction un accès au même stockage sous-jacent. Si la fonction modifie le tableau, l’appelant voit le changement.

Types utilisateur (UDT)

Pine v6 a ajouté le mot-clé type, qui permet de définir des types enregistrement nommés avec des champs et des méthodes optionnelles :

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

Les UDT sont l’analogue Pine le plus proche des structs. Ils peuvent avoir des valeurs par défaut pour les champs (comme avec filled = false). Les méthodes s’appellent en notation pointée et reçoivent l’instance UDT comme this. Les champs sont mutables via l’affectation :=.

Qualificateurs de forme : series ou simple

Chaque valeur en Pine — pas seulement les primitifs, mais aussi les composites et les UDT — porte un qualificateur de forme qui décrit son lien avec la ligne de temps des bougies.

  • series — la valeur peut changer à chaque bougie. Elle a un historique : x[1] vous donne la valeur de x sur la bougie précédente. C’est la valeur par défaut pour la plupart des expressions impliquant les prix.
  • simple — la valeur est fixe pour toute l’exécution de la stratégie. Elle n’a pas d’historique utile (accéder à x[1] sur une valeur simple est une erreur de compilation dans la plupart des contextes).
  • const — la valeur est connue à la compilation. Par exemple const int VERSION = 6. Repliable par le codegen ; elle n’apparaît jamais dans la sortie compilée.

Les qualificateurs de forme forment une hiérarchie : constsimpleseries. Une expression qui mélange les formes est promue vers la plus haute (la plus générale). simple int + series float donne series float.

Sémantique de na

na n’est pas un null universel. C’est une valeur typée — na<int> et na<float> sont distincts, et le type est inféré à partir du contexte. Dans une expression comme :

x = na

Pine infère x en series float par défaut (le type le plus utile en pratique pour une série non initialisée). Dans un contexte où le type est contraint — par exemple si vous écrivez ensuite x := 5 — l’inférence se verrouille sur int.

L’arithmétique sur na se propage : na + 1 vaut na. C’est voulu et cohérent : si une valeur est inconnue, toute expression qui en dépend l’est aussi. Le filet de sécurité est nz(x, 0), qui remplace na par une valeur par défaut.

Les opérateurs de comparaison suivent la même règle : na == na vaut na, pas true. C’est le piège le plus fréquent. Pour tester si une valeur est manquante, utilisez na(x) (le prédicat intégré), pas x == na.

Dans notre codegen C++, na correspond à std::optional<T>. L’arithmétique sur std::nullopt produit std::nullopt. Le prédicat na(x) devient !x.has_value(). Les sémantiques se mappent proprement ; la verbosité est plus grande en C++.

Inférence en détail

Pine infère les types à partir du membre droit des affectations. Les règles sont simples une fois qu’on connaît la hiérarchie des qualificateurs de forme :

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)

Le cas intéressant est l’inférence à travers les branches :

x = condition ? close : 0

condition peut être series bool. close est series float. 0 est const int. Pine élargit 0 en series float et le résultat est series float. Le compilateur choisit toujours la forme la plus générale et le type le plus englobant.

Les arguments de fonction contraignent aussi l’inférence. Si un built-in attend un simple int et que vous passez bar_index (qui est series int), vous obtenez une erreur de compilation. C’est là que la rigueur du système de types apparaît : on ne peut pas passer une valeur qui varie bar par bar à un paramètre qui exige une valeur fixée au démarrage.

Représentation dans le codegen C++

Traduire le système de types de Pine en C++ implique de mapper chaque forme et chaque type Pine vers une représentation C++ au bon comportement à l’exécution.

Les primitifs correspondent à double, int64_t, bool et std::string. Le float Pine est toujours en 64 bits ; nous utilisons double partout.

Les series sont représentées par des tampons d’historique à évaluation paresseuse, avec un modèle générique. L’abstraction expose :

template <typename T>
class Series {
public:
    T current() const;
    T at(int bars_ago) const;  // implements the [] operator
    void advance(T next_value);
};

Lorsque le code Pine écrit close[2], le codegen émet close.at(2). Lorsqu’une bougie se termine, le moteur appelle advance() sur chaque series pour faire glisser la fenêtre d’historique.

na correspond à std::optional<T>. Les opérateurs arithmétiques sur Series<std::optional<T>> propagent nullopt exactement comme Pine propage na. Le built-in nz() devient x.value_or(default_value).

Les UDT deviennent de simples struct. Les champs deviennent des membres. Les méthodes deviennent des fonctions membres. L’opérateur d’affectation de champ Pine := devient une affectation C++ classique ; il n’y a pas de setters.

Les tableaux et matrices deviennent std::vector<T> et un mince enrobage 2D autour de std::vector<T>, respectivement.

Trois pièges que nous avons le plus souvent rencontrés

1. Mélanger series et simple promeut vers series — même quand on s’attendait à une constante

myLength = 14
ma = ta.sma(close, myLength)

Si myLength est déclaré à la portée globale et ne change jamais, on pourrait s’attendre à ce que le codegen le traite comme une constante de compilation. Pine l’infère en simple int, ce qui n’est pas const — la valeur est fixée au démarrage de l’exécution, pas à la compilation. La conséquence est que ta.sma(close, myLength) est series float, et non quelque chose que le compilateur peut replier.

Cela compte pour notre codegen parce que nous voulions const-folde certaines tailles de fenêtre pour la performance. Le système de types de Pine refuse à juste titre : simple n’est pas const, et une variable déclarée sans le mot-clé const pourrait en principe être fixée via input.int() — ce qui la rend déterminée au démarrage, pas à la compilation.

2. L’affectation d’un champ UDT ne déclenche pas de méthodes — et c’est voulu

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

Dans un langage orienté objet, on pourrait s’attendre à ce que b.value := 20.0 passe par un setter capable d’appeler doubled() ou de déclencher d’autres effets. En Pine, ce n’est pas le cas. Les champs sont publics et directement mutables. Les méthodes ne sont que des fonctions rattachées au type. Si vous modélisez une machine à états dans un UDT et voulez une validation à l’écriture, il faut appeler explicitement une méthode qui effectue la validation — les écritures de champs contournent tout le reste.

Nous le documentons en interne parce que c’est l’endroit principal où un programme Pine peut ressembler à avoir de l’encapsulation sans en avoir. Le codegen C++ le reflète fidèlement : les écritures de champs sont de simples affectations de membres de struct.

3. request.security est causal par défaut — mais le lookahead par défaut a changé selon les versions

htf_close = request.security("BTCUSDT", "D", close)

Cet appel est correct au niveau des types et compile sans avertissement. Ce qu’il renvoie, c’est le clôture journalier au regard de l’heure de la bougie courante. Si la bougie courante est une 15 minutes en milieu de séance, htf_close porte le clôture de la veille jusqu’à ce que la nouvelle bougie journalière se ferme — c’est l’interprétation causale.

Le piège historique : en Pine v4, request.security avait par défaut lookahead=barmerge.lookahead_on, ce qui permettait d’entrevoir la bougie journalière future avant sa clôture. Cela gonflait les résultats de backtest des stratégies qui utilisaient des données HTF. Pine v5 a changé le défaut en lookahead_off (causal), ce qui est le comportement correct. Pine v6 conserve le défaut v5.

Le système de types ne vous avertit pas, car les deux modes sont type-corrects. Le codegen PineForge applique par défaut la sémantique lookahead_off, en accord avec le comportement de référence Pine v6. Si vous portez une stratégie depuis v4 et que vos résultats de backtest sur PineForge semblent surprenants — c’est souvent la raison.

Pourquoi transpiler Pine nous a rendus plus exigeants

Chaque ambiguïté dans les règles de typage de Pine doit devenir une décision dans le codegen C++. Lorsque la documentation de référence Pine dit « ceci est défini par l’implémentation » ou que deux comportements sont techniquement cohérents avec la spec, nous ne pouvons pas en choisir un au hasard. Il faut tester contre la sortie réelle de TradingView, comprendre quelle branche TradingView a prise, et implémenter cette branche.

Ce processus nous a obligés à lire la référence du langage Pine v6 plus attentivement qu’un utilisateur ne le ferait. Il a aussi produit la plupart de nos cas de test de parité : lorsque nous trouvons un cas limite dans request.security ou strategy.exit où notre codegen diverge de TradingView, nous écrivons un reproducteur minimal, l’ajoutons au corpus validation, et corrigeons le codegen jusqu’à ce que les deux sorties coïncident.

Le système de types est l’endroit où vivent les cas limites. Les qualificateurs de forme — series vs. simple vs. const — ressemblent à un détail d’implémentation, mais ils déterminent quelles opérations sont légales, quelles valeurs peuvent être passées à quelles fonctions, et quelles expressions peuvent être évaluées à la compilation plutôt que bougie par bougie. Les maîtriser, c’est la différence entre un transpileur et un bon transpileur.

Pour aller plus loin

  • Essayer l’API de codegen depuis Claude ou Cursor — transpilez vos propres stratégies Pine v6 et exécutez-les sur des données OHLCV locales. L’outil transpile_pine renvoie la sortie C++ si vous voulez inspecter les représentations de types.
  • Parcourir la galerie — la catégorie validation comprend 142 stratégies conçues précisément pour exercer les cas limites du système de types. Les compteurs de trades indiquent quelles stratégies génèrent assez d’activité pour rendre les erreurs visibles.
  • Lire pourquoi nous avons construit tout ceci — la genèse du moteur et le choix du C++ comme cible de transpilation.