Prouver l'absence de look-ahead dans les backtests multi-timeframe : perturber le futur pour prouver que le passé ne peut pas le voir
Fait partie de la série "Backtests sans illusions".
L'article précédent se terminait sur un diagnostic : le test de décalage d'une bougie (one-bar shift test). Décalez chaque exécution d'une bougie plus tard ; si la performance s'effondre, c'est que vous tradiez dans le passé. Ce test suffit pour une stratégie mono-timeframe, où le look-ahead se cache dans l'exécution — vous avez décidé sur la bougie i et exécuté sur la bougie i.
Les stratégies multi-timeframe ont un second endroit, plus discret, où fuiter, et le test de décalage ne l'atteint pas. Quand une décision sur une bougie 1 minute consulte une trend en 1 heure, la fuite n'est plus dans l'exécution — elle est dans l'indicateur, calculé à partir d'une bougie de timeframe supérieur qui n'a pas encore fini de se former. Cet article traite de cette fuite, et de la façon de prouver — mécaniquement, pas en scrutant le code — que votre moteur ne la comporte pas.
L'affirmation que nous faisons à la fin est forte : notre moteur multi-TF a réussi 25 tests de parité et de fuite sur 25 sur 86,400 bougies de données réelles ETHUSDT en 1m, y compris une sonde à futur décalé qui perturbe chaque bougie future et confirme qu'aucune décision passée ne bouge. Et comme le moteur est identique bit à bit au cœur canonique du bot live, la preuve est une propriété du système live, pas seulement du backtest.
Le champ de mines multi-timeframe

Une stratégie multi-timeframe (multi-TF) prend une décision sur une horloge rapide tout en consultant des horloges lentes. Notre moteur en est une instance concrète : une règle de momentum à triple TF où une trend de timeframe supérieur (HTF) et une trend de timeframe intermédiaire (MTF) conditionnent les entrées, et un croisement de timeframe inférieur (LTF) les déclenche. Sur l'horloge de base à 1m, une entrée se déclenche seulement quand un croisement HMA/HMA3 en 1m est en accord avec la direction de la trend en 1h et de la trend en 15m ; une sortie se déclenche quand une trend de timeframe supérieur s'inverse contre la position, ou que le LTF croise en sens inverse.
Le danger est structurel. Chacune de ces valeurs de timeframe supérieur doit recevoir une réponse sur chaque bougie 1m — 60 fois par heure — et chaque réponse ne doit utiliser que ce qu'un bot live saurait à cet instant précis. Dès qu'une seule de ces quelque 180 lectures par bougie (trois timeframes, trends d'entrée et de sortie, seuils de séparation) touche une bougie qui n'est pas encore imprimée, vous avez du look-ahead. La surface d'exposition à une erreur d'indexation (off-by-one) est immense, et contrairement à une fuite d'exécution, elle ne se signale pas en décalant l'exécution.
Une bougie en formation n'a pas encore de clôture finale
Voici le piège exact. Supposons qu'il soit 10:37 et que vous décidiez sur la bougie 1m qui vient de clôturer. Votre règle veut la trend en 1h. La bougie en 1h qui couvre 10:00–11:00 est encore en formation — sa clôture finale ne sera connue qu'à 10:59:59. Que savez-vous réellement à 10:37 ? Seulement la clôture courante de cette bougie jusqu'ici, qui est la clôture de la bougie de 10:37. La clôture finale de la bougie se situe 23 minutes dans le futur.
Le backtest multi-TF naïf fait quelque chose qui semble totalement innocent : il rééchantillonne (resample) toute la série 1m en 1h une seule fois, en amont, puis, pour chaque bougie 1m, lit "la clôture 1h". Mais la valeur lue pour chaque bougie 1m entre 10:00 et 11:00 est la clôture finale de 10:00–11:00 — un nombre qui, en temps réel, n'existe pas avant la fin de l'heure. Chaque décision à l'intérieur de l'heure se voit discrètement remettre jusqu'à 59 minutes de futur. Et ce n'est pas une fuite mineure : la clôture du timeframe supérieur est à peu près le meilleur prédicteur possible des rendements 1m à court terme que vous êtes sur le point de trader, donc la laisser fuiter revient presque à lire la réponse à l'avance. C'est le canal de fuite par indicateur de la taxonomie, amplifié : le coup d'œil n'est pas d'une bougie, il peut aller jusqu'à une période HTF complète.
Le test de décalage d'une bougie de l'article précédent ne détecte pas cela. Vous pouvez décaler l'exécution d'une bougie plus tard, la série 1h rééchantillonnée reste contaminée — la fuite réside dans la façon dont l'indicateur a été construit, pas dans le moment où vous avez transigé.
La règle du bot live, reproduite à l'identique : la sémantique de bougie clôturée

La règle correcte est celle que le bot live exécute déjà. Dans notre codebase, il s'agit d'une petite classe, RunningCandleBuffer, reprise telle quelle depuis le simulateur de ticks live. Elle agrège en flux les bougies de base en bougies à période fixe et, à chaque bougie de base, calcule l'indicateur HTF à partir d'un tableau très spécifique :
all_closes = np.array(self.closes + [self.current_close], dtype=np.float64)
Lisez cela littéralement. self.closes sont les clôtures finales des bougies déjà clôturées — une bougie n'est ajoutée que lorsqu'une nouvelle frontière de période est franchie, et sa valeur stockée est la clôture de la dernière bougie de base à l'intérieur de la période (candle_buffer.py:39–44). La bougie en formation ne contribue qu'un seul nombre, self.current_close, qui est la clôture courante — la clôture de la bougie de base la plus récente, base_close[i]. C'est une quantité connue à la bougie i par définition. La clôture finale de la bougie en formation n'est jamais utilisée, car elle n'existe pas encore.
Ainsi, l'indicateur HTF à 10:37 voit [..., close(9:00 candle), close(10:37 so far)]. Quand 10:38 s'imprime, le dernier emplacement se met à jour avec close(10:38). Quand 11:00 est franchi, close(10:59) devient la valeur finale de la bougie nouvellement clôturée et un nouvel emplacement en formation s'ouvre. À aucun moment une décision à l'intérieur de l'heure ne touche la clôture finale de 10:00–11:00. C'est cela la sémantique de bougie clôturée (closed-bar semantics) : les bougies clôturées apportent des clôtures définitives, la bougie en formation n'apporte que sa clôture courante.
Notre moteur rapide (engine_multitf.py) en est une réimplémentation vectorisée, compilée avec numba. Au lieu d'une boucle Python avec une liste qui grandit, il précalcule, pour chaque bougie de base i, combien de bougies ont entièrement clôturé (n_closed[i]) et construit la fenêtre HMA/HMA3 comme [closed candle closes…, base_close[i]] — la clôture courante épinglée dans le dernier emplacement (engine_multitf.py:168–169). C'est la même logique mathématique, déroulée pour la vitesse sur trois timeframes avec des seuils de séparation directionnels. Le contrat est explicite : la valeur à la bougie i ne dépend que de base_close[0..i].
Voilà l'affirmation. Le reste de l'article explique comment nous la prouvons, parce qu'une affirmation dans une docstring ne vaut rien.
La parité est nécessaire mais pas suffisante
Un moteur numba vectorisé qui déroule une classe en flux (streaming) sous forme de boucles explicites est exactement l'endroit où prolifèrent les erreurs d'indexation (off-by-one). Le premier verrou est donc un test de parité bit à bit contre la référence canonique, sur une tranche réelle — ETHUSDT 1m, janvier–février 2024, 86,400 bougies.
Nous vérifions deux choses indépendantes contre deux références indépendantes :
- Indicateurs et croisements vs
RunningCandleBufferexécutée bougie par bougie. Pour chaque timeframe, nous rejouons la classe live sur les 86,400 bougies et comparons les événements de croisement — la bougie, la direction, la séparation — pour une correspondance exacte, plus les valeurs HMA/HMA3 à tolérance flottante près (la référence utilisenp.dot, le moteur des boucles explicites, donc l'ordre de sommation diffère à ~1e-15 près). Les croisements coïncident exactement : 408 croisements sur le HTF (1h, longueur HMA 21), 2,792 sur le MTF (15m/14), 3,691 sur le LTF (1m/50). Pas un seul événement de croisement ne diffère en bougie ou en direction. - Trades vs une simulation indépendante en Python pur des règles de trading, pilotée par les propres croisements de la référence. Cela reproduit la boucle du backtest live — les trends d'entrée conditionnent les entrées, un renversement de trend de sortie ou un croisement LTF opposé clôture, exécution sur
open[i+1], frais aller-retour de 0.09%, clôture forcée sur la dernière bougie — sans aucun des mécanismes numba du moteur. Nous comparons ensuite trade par trade : bougies d'entrée/sortie, sens (côté), prix d'entrée/sortie, PnL, raisons de sortie, et temps actif total en position.
Les seuils de séparation utilisés dans le test ne sont pas des valeurs par défaut anodines. Ils sont choisis pour viser un cas limite délicat — un seuil de sortie MTF fixé plus haut que le seuil d'entrée correspondant — ce qui force la branche "première définition d'une trend de sortie alors qu'une position est ouverte", que la référence traite comme un renversement. La parité doit tenir sur les cas limites, pas seulement sur le chemin facile.
Champ par champ, les trades sont identiques : 466 trades pour la configuration double, 211 trades pour la configuration triple, avec un PnL total concordant à 1e-12 près et tous les champs de chaque trade égaux à la tolérance près. Deux implémentations qui ne partagent aucun code — un moteur vectorisé compilé et une boucle Python naïve sur les croisements d'une troisième implémentation — produisent les mêmes trades jusqu'à la dernière décimale.
C'est un résultat solide, et ce n'est pas une preuve d'absence de look-ahead. La parité dit que le moteur rapide reproduit fidèlement la référence. Si la référence elle-même fuitait — si RunningCandleBuffer regardait en avance — la parité reproduirait fidèlement la fuite et passerait quand même. Un accord entre implémentations vous dit qu'elles sont identiques, pas qu'elles sont causales. Pour la causalité, il faut un type de test différent, un qui demande directement au moteur si le passé peut voir le futur.
La sonde à futur décalé : la véritable preuve

La définition du look-ahead est opérationnelle, donc testez-la de manière opérationnelle. Le look-ahead signifie qu'une décision passée dépend de données futures. La contraposée est un test que vous pouvez exécuter : si vous changez le futur et qu'une décision passée bouge, c'est que le passé lisait le futur. Alors changez le futur — brutalement — et observez le passé.
Choisissez un point de coupe j à 60% de la série (bougie 51,840 sur 86,400). Perturbez chaque bougie à partir de j : multipliez toutes les clôtures et ouvertures futures par 1.05. Recalculez toute la pile de signaux pour les trois timeframes sur les données perturbées. Vérifiez ensuite que tout ce qui se trouve strictement avant j est identique bit à bit à l'exécution non perturbée :
j = int(n * 0.6) # bar 51,840
cl2 = cl.copy(); cl2[j:] *= 1.05 # shove the future up 5%
op2 = op.copy(); op2[j:] *= 1.05
base = [precompute_tf_signals(cl, ts, p, L) for (p, L) in tf_params]
pert = [precompute_tf_signals(cl2, ts, p, L) for (p, L) in tf_params]
for s0, s1 in zip(base, pert):
assert eq_nan(s0.hma[:j], s1.hma[:j]) # HMA identical, NaNs included
assert eq_nan(s0.hma3[:j], s1.hma3[:j])
assert np.array_equal(s0.cross[:j], s1.cross[:j]) # every cross event
assert np.array_equal(s0.sep[:j], s1.sep[:j]) # every separation
Pas "proche". Pas "à tolérance près". np.array_equal, avec obligation que les NaN correspondent aux NaN — chaque valeur HMA, chaque valeur HMA3, chaque flag de croisement, et chaque séparation sur les 51,840 bougies passées doivent être exactement le même flottant. Puis la même assertion sur les trades : chaque trade dont la sortie tombe avant j doit rester inchangé champ par champ. Si une poussée de 5% sur le futur modifie ne serait-ce qu'une seule HMA passée à la douzième décimale, c'est qu'une décision passée a consulté le futur, et la sonde échoue.
Notre moteur la réussit — pour les trois timeframes, et pour les deux simulations de trades, double et triple. Perturber 34,560 bougies futures laisse les 51,840 bougies passées et chaque trade qui s'est clôturé parmi elles exactement tels qu'ils étaient. Ce n'est pas un accord entre implémentations ; c'est une démonstration directe que la frontière d'information dans le temps tient bon.
Un test qui ne peut pas échouer ne prouve rien
Il existe une façon de réussir la sonde ci-dessus qui ne prouve rigoureusement rien : si la perturbation était un no-op — si multiplier le futur par 1.05 ne changeait rien nulle part — alors "le passé est inchangé" serait trivialement vrai et totalement non informatif. Un test au vert qui ne peut pas échouer est pire que l'absence de test, car il fabrique une fausse confiance. La sonde comporte donc deux assertions supplémentaires qui lui donnent du mordant.
Le futur doit réellement changer. Nous vérifions que la perturbation a bien modifié des croisements quelque part dans [j, n) :
assert not np.array_equal(s0.cross[j:], s1.cross[j:]) # probe has teeth
Le résultat prend alors tout son sens : la même poussée de 5% qui a réécrit le futur a laissé le passé identique bit à bit. La perturbation est réelle, elle se propage vers l'avant, et elle s'arrête net à la coupe. Une fuite à sens unique — le passé lit le futur — aurait saigné en arrière à travers j ; ce n'est pas le cas.
La frontière se situe exactement à la bougie courante — ni une bougie trop tôt, ni une trop tard. Un échec plus subtil serait un moteur causal mais à la traîne (stale) : il ignore la clôture courante de la bougie en cours et réagit avec une bougie de retard (pas de fuite, mais un délai que le trading live n'aurait pas), ou bien il réagit une bougie trop tôt (une fuite d'une bougie). Nous perturbons donc une seule bougie j (de 1.02) et vérifions deux choses à la fois : le passé [0, j) est intact, et hma[j] réagit immédiatement.
cl3 = cl.copy(); cl3[j] *= 1.02 # nudge exactly one bar
s3 = precompute_tf_signals(cl3, ts, p_ltf, L_ltf)
assert eq_nan(s0.hma[:j], s3.hma[:j]) # nothing before j moves
assert s0.hma[j] != s3.hma[j] # bar j reacts on the same bar
Cela fixe la frontière avec précision. La clôture courante de la bougie en formation entre dans l'indicateur à la bougie j avec zéro délai et zéro anticipation : la bougie j voit sa propre clôture instantanément, et aucune bougie antérieure ne la voit jamais. C'est exactement le fil du rasoir sur lequel la sémantique de bougie clôturée est censée se tenir, et le test confirme que le moteur s'y tient.
Voici le verrou complet — les 25 tests qui se dressent entre ce moteur et un backtest fabriqué de toutes pièces :
| Groupe | Ce que chaque test vérifie | Nombre |
|---|---|---|
| Indicateurs & croisements (×3 timeframes) | événements de croisement exacts vs RunningCandleBuffer ; séparation aux croisements ; valeurs HMA/HMA3 (rtol 1e-9) |
9 |
| Trades (double + triple) | nombre de trades ; identiques champ par champ ; PnL total à 1e-12 près ; temps actif en position | 8 |
| Sonde à futur décalé (double + triple) | signaux passés inchangés bit à bit ; la sonde a du mordant (le futur a bien changé) ; trades avant j inchangés ; perturbation d'une seule bougie localisée |
8 |
| Total | 25 |
Les deux premiers groupes établissent que le moteur rapide est la référence live. Le troisième établit que la référence est causale. Les trois sont nécessaires : un moteur rapide qui fuit, un moteur qui fuit mais est causal, et un moteur causal mais en retard sont trois échecs différents, et le verrou élimine chacun d'eux.
Pourquoi la sonde est agnostique au timeframe
L'élégance de la sonde à futur décalé est qu'elle ne sait pas, et n'a pas besoin de savoir, où une fuite se logerait. Elle ne mentionne jamais les timeframes, le rééchantillonnage, ou les frontières de bougies. Elle demande seulement : perturber le futur fait-il bouger le passé ? C'est précisément ce qui en fait le bon outil pour la fuite multi-TF, que le test de décalage d'exécution laisse passer.
Considérons directement le bug naïf du rééchantillonnage de toute la série. Si le flux 1h était construit en rééchantillonnant la série complète en amont, la lecture de "clôture 1h" à la bougie j-1000 (bien à l'intérieur de l'heure qui clôture après j) serait la clôture finale d'une bougie dont la clôture finale dépend de bougies situées à j ou au-delà. Multipliez le futur par 1.05 et cette clôture finale change — donc l'indicateur HTF à j-1000 change, le gate à j-1000 change, et une décision passée bouge. La sonde s'allumerait instantanément sur le flux HTF, à une bougie se trouvant mille pas avant la coupe.
Le flux HTF de notre moteur ne bouge pas, car à j-1000 la bougie en formation ne contribue que base_close[j-1000] — une clôture passée — et la clôture finale de la bougie n'est tout simplement jamais consultée avant que la frontière ne soit franchie. La sonde est aveugle au mécanisme et détecte quand même le bug, ce qui est exactement ce qu'on attend d'une preuve : elle contraint le comportement (aucune décision passée ne dépend de données futures) plutôt que d'auditer l'implémentation (avons-nous indexé le rééchantillonnage correctement ?). Le comportement est ce qui trade ; l'implémentation est ce qu'on espère voir concorder avec lui.
Le backtest et le live partagent une seule vérité

Il y a une raison supplémentaire pour laquelle cela compte plus qu'un audit de backtest classique. La référence contre laquelle le moteur est prouvé — RunningCandleBuffer — n'est pas un fixture de test écrit pour faire bien paraître le backtest. C'est la logique de bougie propre au bot live, reprise telle quelle depuis le simulateur de ticks qui tourne en production. La règle de bougie clôturée que la sonde valide est la règle que le bot live exécute, bougie par bougie.
Le verrou de parité remplit donc une double fonction. Il prouve que le moteur rapide correspond à la référence, et comme la référence est le cœur live, il prouve que le moteur rapide correspond au live. L'article précédent avertissait qu'une fuite est l'explication la plus simple à un écart de parité backtest-live — le bot live est le seul endroit où l'on ne peut mécaniquement pas tricher en regardant en avance, donc un backtest qui triche diverge dès qu'il passe en live. Ici, cet écart est fermé par construction : le backtest et le bot partagent un seul buffer de bougies, une seule règle de croisement, une seule définition de "connu à la bougie i". Le nombre que la recherche optimise est le nombre que le bot peut effectivement trader.
C'est là tout l'intérêt de prouver l'absence de look-ahead plutôt que de la supposer. Une recherche multi-TF exécute des milliers de configurations ; si le moteur fuitait, la recherche trouverait la configuration qui exploite la fuite le plus agressivement et vous remettrait un gagnant fabriqué de toutes pièces — le mode d'échec que la taxonomie mesurait à un Sharpe de 15 à partir de pur bruit. La sonde est ce qui vous permet de faire confiance au fait que le gagnant est réel avant de le brancher sur du capital.
Ce que la sonde prouve, et ce qu'elle ne prouve pas
La rigueur envers le test joue dans les deux sens, alors soyons précis sur sa portée. La sonde à futur décalé prouve une seule propriété, spécifique : sur ces données, aucune décision de signal ou de trade à la bougie j ou avant ne dépend d'une bougie après j — la frontière d'information dans le temps tient à travers le chemin indicateur, croisement et trade. C'est exactement la fuite multi-TF que nous cherchions à éliminer, et c'est la propriété qu'une revue de code ne peut pas établir.
Elle ne prouve pas que la stratégie a un edge. Un moteur parfaitement causal peut perdre de l'argent honnêtement, et la sonde reste muette là-dessus — ce qui est normal ; prouver l'absence de fuite et trouver un edge réel sont deux tâches distinctes, et les confondre est précisément comment des backtests qui fuient finissent déployés. Elle ne couvre pas les biais non temporels : le survivorship sur l'instrument, le biais de sélection consistant à ne lancer la sonde qu'une fois que le moteur avait l'air bon, ou un modèle de frais trop clément. Et elle ne prouve pas, à elle seule, que les exécutions live correspondent aux exécutions du backtest — le slippage et la latence sont des écarts réels que la sonde ne peut pas voir, car elle opère sur le chemin de décision, pas sur la plateforme d'exécution. Ce qui ferme cet écart-là, c'est le fait distinct que le cœur de bougie du moteur est identique bit à bit à celui du bot live.
Une réserve honnête sur la conception même de la sonde : elle coupe en un seul point j (60% de la série). La propriété qu'elle vérifie est uniforme en j — rien de spécial dans la bougie 51,840 — donc une seule coupe constitue un test valable d'une propriété structurelle, mais une version paranoïaque balaierait j sur toute la série. Nous considérons qu'une coupe bien choisie, plus le test de localisation sur une seule bougie, sont suffisants, car une fuite qui se cacherait d'une perturbation future sur 34,560 bougies mais apparaîtrait à une autre coupe devrait être un bug très étrange. L'essentiel est de connaître les limites de sa propre preuve, pas de prétendre qu'un test unique équivaut à un quantificateur universel.
Points à retenir
- Les stratégies multi-timeframe fuient par la bougie en formation, pas par l'exécution. Une décision à l'intérieur d'une période HTF non terminée ne doit utiliser que la clôture courante de cette bougie (la dernière clôture de base), jamais sa clôture finale. Les backtests qui rééchantillonnent toute la série remettent à chaque décision intra-période jusqu'à une période HTF complète de futur.
- Le test de décalage d'une bougie n'atteint pas cette fuite. Il détecte le look-ahead d'exécution ; la fuite multi-TF réside dans la façon dont l'indicateur a été construit. Il faut une sonde différente.
- Reproduisez la règle live à l'identique, puis prouvez-la. Nous avons reconstruit la sémantique de bougie clôturée
RunningCandleBufferdu bot sous forme de moteur vectorisé et l'avons verrouillée derrière 25 tests : croisements exacts contre la référence (408 / 2,792 / 3,691), trades identiques champ par champ (466 double, 211 triple), PnL à 1e-12 près. - La parité est nécessaire mais pas suffisante. Correspondre à une référence prouve que vous êtes identiques, pas que vous êtes causaux. Une référence qui fuit, reproduite fidèlement, continue de fuir.
- La sonde à futur décalé est la véritable preuve. Perturbez chaque bougie à
jou après ; vérifiez que chaque signal et chaque trade avantjreste inchangé bit à bit. Si le futur peut faire bouger le passé, vous avez du look-ahead. - Donnez du mordant à la sonde. Vérifiez que le futur a bien changé (la perturbation n'est pas un no-op) et qu'une petite poussée sur une seule bougie réagit sur cette même bougie (pas de fuite, pas de retard). Un test qui ne peut pas échouer ne prouve rien.
- Quand le backtest et le live partagent un seul cœur, la preuve se transfère. Comme le moteur est identique bit à bit à la logique de bougie du bot live, l'absence de look-ahead est aussi une propriété du système live — et l'écart de parité backtest-live se ferme par construction.
L'article précédent montrait comment une fuite d'une seule ligne fabrique un Sharpe de 15. Celui-ci montre la discipline inverse : non pas "comment les fuites vous trompent", mais comment prouver, mécaniquement et sur des données réelles, qu'un moteur donné ne fuit pas. Perturbez le futur. Si le passé ne bronche pas, c'est que vous tradez dans le présent.
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.