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++.
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)
-> moderateCette 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=0strategy.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=14fill.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=YESMarge 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.69La 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 $.
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 :
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% → excellentLorsque 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%
-> excellentLa 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 :
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
- Essayer l’API de génération de code depuis Claude ou Cursor — transpilez
vos propres stratégies Pine et exécutez-les sur de l’OHLCV local. L’outil
transpile_pinerenvoie la sortie C++ si vous voulez voir comment le contrôle de marge est maintenant câblé. - Parcourir la galerie — les 162 stratégies avec leur palier de parité
actuel. La fiche
community/IESaffiche désormaisexcellent. - Demander un accès anticipé — l’offre gratuite inclut 100 transpilations par mois, suffisant pour amorcer votre propre corpus de parité en local.