pineforge
Ingénierie

Marge au signal vs au fill : une histoire de parité TradingView

Notre émulateur appliquait la marge au fill sur l’open de la barre suivante ; TradingView au signal sur le close courant. 3 cents d’écart ont fait disparaître 25 trades sur 2.632. Comment six sondes ont isolé le bug sur une ligne de C++.

11 min de lecture#parity#engine#tradingview#debugging#broker-emulation

Une stratégie Pine communautaire de notre corpus de parité nommée IES restait en parité moderate vis-à-vis de TradingView depuis des semaines. Les décomptes coïncidaient. Les prix d’entrée, au centime près. Les prix de sortie, idem. Mais le PnL cumulé dérivait de 29 281 $ sur 2 632 opérations déjà appariées.

Voici comment nous avons identifié la cause : un seul contrôle de marge exécuté quatre centimes de prix trop tard.

L’empreinte asymétrique

Voici ce que verify_corpus.py remontait sur la stratégie avant que nous n’approfondissions l’analyse :

community/IES (pre-fix)
  Profile:       strict
  TV trades:     2668  (raw 2668)
  Engine trades: 2663  (raw 2663)
  Matched:       2632 (98.7% of TV)
  Count delta:             0.1874%  (OK)
  Entry-price p90 delta:   0.0000%  (OK)
  Exit-price  p90 delta:   0.0000%  (OK)
  PnL         p90 delta:   2.5412%  (X)
  -> moderate

Cette ligne est en elle-même un diagnostic. Lorsque le nombre d’opérations, le prix d’entrée et le prix de sortie s’alignent mais que le PnL par trade dérive d’environ 2,5 %, on n’est pas face à un bogue de logique de signal ni à un bogue de prix d’exécution : les deux moteurs sélectionnent les mêmes trades aux mêmes prix. Ce qui change, c’est la taille des positions.

En remontant à partir de la dernière opération de la fenêtre de comparaison, l’écart reconstitué sur les intrants de dimensionnement reproduisait exactement la différence d’équité cumulée de 29 281 $. La divergence résidait dans strategy.equity — la variable récursive de rétroaction dont dépend toute formule de dimensionnement — et non dans l’arithmétique trade par trade.

Ce que nous avons essayé en premier (et pourquoi rien n’a marché)

Avant la bonne hypothèse, nous avons épuisé trois mauvaises. Elles méritent d’être racontées : en débogage réel, l’essentiel du travel consiste à éliminer des hypothèses, pas à les confirmer.

Écart de préchauffage des indicateurs. Le moteur préchauffe sa pile d’analyse technique avec cinq mois d’OHLCV avant l’ouverture de la fenêtre de comparaison ; l’historique affiché sur le graphique TradingView peut ne pas s’étendre aussi loin. Nous avons bâti un harnais de traçage barre par barre et, ce faisant, nous avons identifié huit bogues réels d’indicateurs — sémantique de ta.pivothigh / ta.pivotlow, formule de ta.iii, génération de code et échelle pour ta.tsi, pondération de ta.cog, pourcentage pour ta.bbw, ancre journalière de ta.vwap, ta.supertrend.line, HLC de la barre précédente pour ta.pivot_point_levels, et sémantique des égalités ignorées pour ta.cross. Nous avons corrigé les huit. Aucun d’eux n’a fait bouger l’aiguille pour IES.

Moment d’exécution des stops / des ordres limites. Une autre stratégie communautaire (scalping-wunder-bots) présentait un schéma net : le moteur sortait une barre plus tôt que TradingView. Nous avons supposé que le moteur évaluait strategy.exit sur la barre d’entrée tandis que TV reportait à la barre suivante. Nous avons créé parity-probe-01-stop-limit-timing pour isoler précisément ce scénario. Résultat : 778 opérations sur 778 appariées, les quatre garde-fous stricts au vert. L’hypothèse était réfutée.

Suivi des swings CHoCH / BOS. Sur une autre stratégie (VCP), une entrée parasite le 12 mai 2025 à 12:15 semblait provenir d’une évolution divergente de l’état des pivots. Nous avons bâti parity-probe-02-choch-bos-isolator en reproduisant exactement sa signature de pivots. 1 026 sur 1 026 appariées. Encore une fois réfuté.

Après trois sondes, aucune amélioration sur IES, mais une méthode claire : écrire des stratégies Pine minimales qui isolent une hypothèse à la fois allait beaucoup plus vite que de tracer des variables internes au sein d’un script communautaire de 700 lignes. Nous en avons donc écrit une quatrième.

La sonde qui a trouvé le bug

L’astuce avec parity-probe-03-equity-mirror, c’est que la quantité de l’ordre encode directement strategy.equity :

//@version=6
strategy("Parity probe 03 - equity mirror",
         shorttitle="par_p03", overlay=true, initial_capital=1000000,
         default_qty_type=strategy.fixed, default_qty_value=1,
         pyramiding=0, process_orders_on_close=false,
         commission_value=0, slippage=0)
 
bool fire = dayofweek == 2 and hour == 0 and minute == 0
            and strategy.position_size == 0
 
float qty_dyn = math.round(strategy.equity / close * 1000) / 1000
 
if fire and qty_dyn > 0
    strategy.entry("E", strategy.long, qty=qty_dyn)
 
if strategy.position_size > 0 and bar_index >
        strategy.opentrades.entry_bar_index(0)
    strategy.close("E")

qty = round(strategy.equity / close * 1000) / 1000 arrondi à trois décimales fait de la colonne exportée Size (qty) du CSV « List of Trades » de TradingView un oracle par barre de la valeur de strategy.equity côté TV. On apparie moteur et TV sur l’horodatage d’entrée, on aligne les colonnes de quantité, et on lit l’écart d’équité directement sur le diff.

Les huit premiers trades appariés :

2025-03-31 00:15  eng_qty=553.1490  tv_qty=553.1490  diff=+0.000%
2025-04-14 00:15  eng_qty=612.9970  tv_qty=612.1600  diff=+0.137%
2025-04-21 00:15  eng_qty=623.1630  tv_qty=622.3110  diff=+0.137%
2025-08-25 00:15  eng_qty=207.2020  tv_qty=209.8110  diff=-1.244%
2025-11-03 00:15  eng_qty=242.4760  tv_qty=251.9860  diff=-3.774%
2025-11-24 00:15  eng_qty=339.1820  tv_qty=361.0170  diff=-6.048%
2025-12-22 00:15  eng_qty=307.0180  tv_qty=330.5680  diff=-7.124%
2026-01-05 00:15  eng_qty=295.2650  tv_qty=320.1020  diff=-7.759%

Le premier trade coïncide parfaitement. Les deux côtés démarrent avec 1 M$ d’équité. À partir du trade 2, les quantités divergent et ne se recollent plus. L’écart se stabilise autour de 7,76 % au huitième trade.

La cause n’était pas une dérive de calcul progressive : c’était un trade manqué. L’entrée qui aurait dû être le trade 2 — le 7 avril 2025 — apparaissait dans la liste de trades de TV, pas dans celle du moteur. TV exécutait l’opération et encaissait une perte de 1 344 $, son équité tombant à environ 984 k$. Le moteur n’entrait jamais, son équité restant autour de 985 k$. Dès lors, le moteur était sur-dimensionné sur chaque opération suivante, les pertes composées divergeant selon des trajectoires d’équité différentes, et treize mois plus tard l’écart atteignait 29 281 $.

Un fill fantôme

Nous avons ajouté des journaux std::cerr dans strategy_entry, process_pending_orders, enter_market_from_flat et apply_market_order_fill, le tout derrière une variable d’environnement pour éviter de polluer les autres exécutions de tests.

Sur la barre de signal, le 7 avril 2025 à 00:00 :

[entry CALL] ts=1743984000000 bar=16236 id=E is_long=1 qty=627.312
             pending_orders.size=0 pos_qty=0 pos_side=0

strategy.entry a été appelé. L’ordre a été mis en file. Jusque-là, tout va bien.

Sur la barre d’exécution, le 7 avril 2025 à 00:15 :

[ppo] ts=1743984900000 bar=16237 pass=0 i=0 id=E type=0 is_long=1
      qty=627.312 eligib=FILL_TRY
    -> fill.kind=0 fill_price=1566.69
    -> AFTER apply: pos_qty=0 pos_side=0 pyramid.size=0 trades.size=14

fill.kind=0 correspond à FillEvaluation::Kind::Fill. L’évaluateur de prix disait d’exécuter. apply_filled_order_to_state a été appelé. Et l’état de position ne s’est pas mis à jour. Un fill fantôme.

Une ligne de débogage supplémentaire, cette fois dans enter_market_from_flat, affichant les intrants du garde-fou de marge :

[margin] ts=1743984900000 qty=627.312 fill_px=1566.69 margin_pct=100
         required=982803 available_equity=982785 net_profit_sum=-17215.4
         initial=1e+06 block=YES

Marge requise : 982 803 $. Équité disponible : 982 785 $. Le garde-fou exigeait 18,91 $ de plus que le compte n’en avait, revenait immédiatement sans mettre l’état à jour, et ne produisait ni erreur ni ligne de log visible côté stratégie. Du point de vue du script, le trade n’avait tout simplement pas existé.

Pourquoi TradingView a pris le même trade

Voici l’OHLC pour la paire de barres concernée :

04-07 00:00 (signal): O=1579.98  H=1585.57  L=1566.00  C=1566.66
04-07 00:15 (fill):   O=1566.69

La sonde dimensionnait l’ordre au clôture de la barre de signal : qty = round(985319.43 / 1566.66 * 1000) / 1000 = 628.93. La marge requise calculée à la clôture de signal coïncide exactement avec l’équité disponible. La marge requise recalculée à l’ouverture de la barre suivante la dépasse de 18,91 $.

Price usedRequiredEquityOvershoot
Signal close ($1566.66)$985,319.47$985,319.43+$0.04
Fill open ($1566.69)$985,338.34$985,319.43+$18.91

Trois centimes de mouvement sur l’ETH, multipliés par 628,93 contrats, ont produit 18,91 $ de marge requise supplémentaire. Le contrôle de marge de notre moteur s’effectuait au moment de l’exécution avec le prix d’exécution — et rejetait. L’émulateur de courtier de TradingView, selon notre conviction désormais étayée, exécutait le même contrôle au moment du signal avec la clôture de la barre de signal — et acceptait, car quatre centimes sur une position d’environ 985 k$ se situent en deçà de la tolérance en virgule flottante que TV applique à la limite.

Triangulation avec trois sondes supplémentaires

Pour prouver qu’il ne s’agissait pas d’une coïncidence sur un seul trade — et que le bogue concernait spécifiquement la frontière 1× équité — nous avons bâti trois sondes supplémentaires qui cernent le phénomène :

ProbeSizingMargin pressure
parity-probe-04-percent-of-equity-sizingdefault_qty_type=percent_of_equity, default_qty_value=99~99% equity
parity-probe-05-small-equity-fractionqty = round(equity / close * 100) / 1000~10% equity
parity-probe-06-edge-margin-sizingqty = round(equity * 0.5 / close * 1000) / 1000~50% equity

Nous les avons publiées sur TradingView, exporté les listes de trades, exécuté le diff :

parity-probe-04: TV=57  engine=57  matched=57/57 (100.0%)  PnL p90=0.0006%  → excellent
parity-probe-05: TV=57  engine=57  matched=57/57 (100.0%)  PnL p90=0.0698%  → excellent
parity-probe-06: TV=57  engine=57  matched=57/57 (100.0%)  PnL p90=0.0700%  → excellent

Lorsque le dimensionnement n’était pas coinçé sur la frontière de marge 1× équité, le moteur et TV concordaient parfaitement sur les 57 trades de chaque sonde. Le défaut n’apparaissait qu’à la frontière, où le glissement entre clôture de signal et ouverture d’exécution pouvait inverser l’issue du contrôle de marge.

Le correctif

Déplacer le contrôle de marge de enter_market_from_flat (exécuté au moment du fill, avec le prix d’exécution) vers strategy_entry (exécuté au moment du signal, avec la clôture de la barre en cours) :

 void BacktestEngine::strategy_entry(const std::string& id, bool is_long,
                                     double limit_price, double stop_price,
                                     double qty, ...) {
     if (!trading_is_active(...)) return;
+
+    // TradingView broker rule: market-entry orders are admitted only
+    // when qty * <signal-bar close> * margin_pct/100 <= equity. Check
+    // happens HERE at signal time with current_bar_.close, NOT later
+    // in apply_market_order_fill where fill_price is the next bar's
+    // open. Verified empirically by parity-probe-{03..06}.
+    if (!std::isnan(qty) && std::isnan(limit_price) && std::isnan(stop_price)) {
+        double margin_pct = is_long ? margin_long_ : margin_short_;
+        if (margin_pct > 0.0 && !std::isnan(current_bar_.close)) {
+            double required = std::abs(qty) * current_bar_.close * (margin_pct / 100.0);
+            double available = current_equity();
+            double epsilon = std::max(1e-9, std::abs(available) * 1e-12);
+            if (required > available + epsilon) {
+                return;
+            }
+        }
+    }
     ...

Cinq lignes, plus la suppression correspondante dans enter_market_from_flat. Nouvelle exécution sur IES :

community/IES (post-fix)
  TV trades:     2668  (raw 2668)
  Engine trades: 2643  (raw 2643)
  Matched:       2618 (98.1% of TV)
  Count delta:             0.9370%  (OK)
  Entry-price p90 delta:   0.0000%  (OK)
  Exit-price  p90 delta:   0.0000%  (OK)
  PnL         p90 delta:   0.7809%  (OK)  ← was 2.5412%
  -> excellent

La dérive du PnL est passée de 2,49 % à 0,78 %. IES est passée de moderate à excellent. Sur le reste du corpus : 168 stratégies dont 165 en excellent, 2 en strong, 0 en moderate. Les 16 binaires ctest passent, les 313 cas pytest de génération de code passent, sans régression sur les 161 autres stratégies communautaires ou de validation.

Le rebondissement

Après le correctif, parity-probe-03-equity-mirror lui-même ne correspondait toujours pas entièrement à TV. Le moteur enregistrait 25 entrées, TV 24, et seulement 13 se recoupaient. La sonde-03 est conçue pour vivre exactement sur la frontière 1× équité : tout écart de tolérance entre les deux implémentations s’y voit avant ailleurs.

Pour chaque lundi de la fenêtre de comparaison, nous avons calculé qty * signal_close - equity et noté la décision d’acceptation ou de rejet de TV :

MondayOvershoot at signal closeTV action
2025-04-07+$0.04TAKE
2025-04-14-$0.76TAKE
2025-04-21-$0.19TAKE
2025-04-28+$0.34REJECT
2025-05-12-$0.18REJECT (under equity, yet rejected)
2025-05-26-$0.89REJECT (under equity, yet rejected)
2025-08-18+$0.24REJECT
2025-11-10+$0.24TAKE (identical overshoot, opposite outcome)

Le 18 août et le 10 novembre portent le même dépassement de +0,24 $ et aboutissent à des décisions opposées. Aucune règle de marge pure — quel que soit le seuil ou la tolérance — ne peut reproduire ce schéma. L’émulateur de courtier de TV, sur cette frontière, dépend de quelque chose qui n’apparaît dans aucune variable Pine documentée : l’historique de chargement du graphique, l’ordre dans lequel les barres sont devenues visibles pour la stratégie au moment de la publication, ou un état interne caché que nous n’avons aucun moyen d’observer.

La lecture honnête : il s’agit d’un angle mort non documenté de l’émulateur TradingView, qui ne se manifeste que lorsque le dimensionnement est calé exactement sur la frontière 1× équité. Les stratégies réelles laissent une marge de manœuvre — community/IES dimensionne autour de 1 à 1,4 % de l’équité par trade — donc cela n’impacte pas les utilisateurs en pratique. Nous avons choisi de conserver le correctif et de documenter la sonde-03 comme limitation connue. Un léger abaissement de palier sur la sonde de diagnostic est un prix acceptable pour le gain obtenu sur IES.

Six enseignements tirés de cette histoire de débogage

1. Les empreintes métriques asymétriques révèlent la classe de bogue. Quand le nombre d’opérations, les prix d’entrée et de sortie concordent mais que le PnL dérive, on est face à un problème de dimensionnement. Nous aurions dû examiner le code lié au sizing des semaines plus tôt plutôt que d’auditer les formules d’indicateurs.

2. Les sondes d’isolement passent mieux à l’échelle que le traçage de grosses stratégies. Six petites sondes Pine, chacune testant une hypothèse, ont permis de trouver le bogue en deux après-midi. Tracer chaque variable interne dans un script communautaire de 700 lignes avec calcul d’équité en rétroaction aurait pris plus longtemps et produit plus de bruit. C’est la même raison pour laquelle on écrit des tests unitaires plutôt que de ne déboguer qu’au niveau intégration.

3. Le paramètre qty= de TradingView a une sémantique de marge au moment du signal. Le contrôle porte sur la barre où strategy.entry est appelé, pas sur la barre d’exécution. Ce n’est pas documenté dans la référence Pine. Toute implémentation d’un émulateur de courtier compatible Pine devrait s’y conformer ; l’implémentation « naturelle » place le contrôle au moment du fill (où le prix d’exécution est connu) et casse le comportement sans bruit.

4. La virgule flottante aux frontières reste l’ennemi. Le seul trade à l’origine de 29 k$ de dérive composée était un dépassement de quatre centimes sur une position d’environ 985 k$. En production, les stratégies devraient prévoir une marge de sécurité de 1 à 5 % (un * 0.99 ou * 0.95 dans la formule de dimensionnement) pour éviter de vivre sur la frontière.

5. La composition amplifie des manques sur une seule opération. Le manque du 7 avril représentait à lui seul 1 344 $. Treize mois plus tard, cela s’était transformé en un écart d’environ 29 000 $ — soit environ 22× le manque initial — parce que chaque trade manquant déplaçait la trajectoire d’équité, modifiait le dimensionnement suivant, puis la décision d’acceptation ou de rejet suivante. Les stratégies à rétroaction d’équité sont impitoyables face à de petites ruptures d’exécution isolées.

6. Parfois le bogue est de l’autre côté, et on le documente avec honnêteté. Le comportement de TV sur la frontière 1× est un vrai coin sombre que nous ne pouvons pas corriger depuis notre code. Le documenter franchement vaut mieux que de poursuivre en silence une métrique intrinsèquement inatteignable.

La méthode se généralise au-delà de ce bogue précis. Les sondes de parité complètent notre passage de validation croisée PyneCore : PyneCore indique quand notre moteur diverge d’un autre moteur ; les sondes de parité expliquent pourquoi il diverge de TradingView. Lorsque nous découvrons un nouvel écart, la ligne de conduite est désormais : écrire le plus petit script Pine possible qui l’illustre, le publier sur TV, comparer les listes de trades, instrumenter le moteur, corriger l’écart, documenter tout angle mort TV qu’on ne peut pas réconcilier.

La suite