Proving No Look-Ahead in Multi-Timeframe Backtests: Perturb the Future, Prove the Past Can't See It
Part of the "Backtests Without Illusions" series.
The previous article ended on a diagnostic: the one-bar shift test. Move every fill one bar later; if performance collapses, you were trading in the past. That test is enough for a single-timeframe strategy, where look-ahead hides in execution — you decided on bar i and filled on bar i.
Multi-timeframe strategies have a second, quieter place to leak, and the shift test does not reach it. When a decision on a 1-minute bar consults a 1-hour trend, the leak is no longer in the fill — it is in the indicator, computed from a higher-timeframe bar that has not finished forming yet. This article is about that leak, and about how to prove — mechanically, not by staring at code — that your engine does not have it.
The claim we make at the end is strong: our multi-TF engine passed 25 out of 25 parity and leakage checks on 86,400 bars of real ETHUSDT 1m data, including a shifted-future probe that perturbs every future bar and confirms not one past decision moves. And because the engine is bit-identical to the live bot's canonical core, the proof is a property of the live system, not just the backtest.
The multi-timeframe minefield

A multi-timeframe (multi-TF) strategy makes a decision on a fast clock while consulting slow clocks. Our engine is a concrete instance: a triple-TF momentum rule where a higher-timeframe (HTF) trend and a mid-timeframe (MTF) trend gate entries, and a lower-timeframe (LTF) cross triggers them. On the 1m base clock, an entry fires only when a 1m HMA/HMA3 cross agrees with the direction of the 1h trend and the 15m trend; an exit fires when a higher-timeframe trend reverses against the position, or the LTF crosses back.
The danger is structural. Every one of those higher-timeframe values has to be answered on every 1m bar — 60 times per hour — and each answer must use only what a live bot would know at that instant. The instant a single one of those 180-odd per-bar reads (three timeframes, entry and exit trends, separation gates) touches a bar that has not printed yet, you have look-ahead. The surface area for an off-by-one is enormous, and unlike an execution leak, it does not announce itself by shifting the fill.
A forming bar has no final close yet
Here is the exact trap. Suppose it is 10:37 and you are deciding on the 1m bar that just closed. Your rule wants the 1h trend. The 1h candle that covers 10:00–11:00 is still forming — its final close will not be known until 10:59:59. What do you actually know at 10:37? Only the running close of that candle so far, which is the close of the 10:37 bar. The candle's final close is 23 minutes in the future.
The naive multi-TF backtest does something that looks completely innocent: it resamples the whole 1m series to 1h once, up front, and then, for each 1m bar, reads "the 1h close." But the value it reads for every 1m bar between 10:00 and 11:00 is the final 10:00–11:00 close — a number that, in real time, does not exist until the hour ends. Every decision inside the hour is quietly handed up to 59 minutes of the future. And this is not a small leak: the higher-timeframe close is about the strongest possible predictor of the near-future 1m returns you are about to trade, so leaking it is close to reading the answer key. This is the indicator-leakage channel from the taxonomy, amplified: the peek is not one bar, it is up to a full HTF period.
The one-bar shift test from the prequel does not catch this. You can shift the fill one bar later and the resampled 1h series is still contaminated — the leak lives in how the indicator was built, not in when you transacted.
The live bot's rule, reproduced exactly: closed-bar semantics

The correct rule is the one the live bot already runs. In our codebase it is a small class, RunningCandleBuffer, moved verbatim out of the live tick simulator. It streams base bars into fixed-period candles and, on every base bar, computes the HTF indicator from a very specific array:
all_closes = np.array(self.closes + [self.current_close], dtype=np.float64)
Read that literally. self.closes are the final closes of candles that have already closed — a candle is appended only when a new period boundary is crossed, and its stored value is the close of the last base bar inside the period (candle_buffer.py:39–44). The forming candle contributes exactly one number, self.current_close, which is the running close — the close of the most recent base bar, base_close[i]. That is a quantity known at bar i by definition. The forming candle's final close is never used, because it does not exist yet.
So the HTF indicator at 10:37 sees [..., close(9:00 candle), close(10:37 so far)]. When 10:38 prints, the last slot updates to close(10:38). When 11:00 crosses, close(10:59) becomes the newly closed candle's final value and a fresh forming slot opens. At no point does a decision inside the hour touch the final 10:00–11:00 close. That is closed-bar semantics: closed candles contribute finished closes, the forming candle contributes only its running close.
Our fast engine (engine_multitf.py) is a vectorized, numba-compiled reimplementation of this. Instead of a Python loop with a growing list, it precomputes, for every base bar i, how many candles have fully closed (n_closed[i]) and lays the HMA/HMA3 window as [closed candle closes…, base_close[i]] — the running close pinned into the final slot (engine_multitf.py:168–169). It is the same math, unrolled for speed across three timeframes with directional separation gates. The contract is explicit: the value at bar i depends only on base_close[0..i].
That is the claim. The rest of the article is how we prove it, because a claim in a docstring is worth nothing.
Parity is necessary but not sufficient
A vectorized numba engine that unrolls a streaming class into explicit loops is exactly where off-by-ones breed. So the first gate is a bit-for-bit parity test against the canonical reference, on a real slice — ETHUSDT 1m, January–February 2024, 86,400 bars.
We check two independent things against two independent references:
- Indicators and crosses vs
RunningCandleBufferrun bar-by-bar. For each timeframe we replay the live class over all 86,400 bars and compare cross events — the bar, the direction, the separation — for an exact match, plus the HMA/HMA3 values to floating-point tolerance (the reference usesnp.dot, the engine explicit loops, so summation order differs at ~1e-15). The crosses line up exactly: 408 crosses on the HTF (1h, HMA length 21), 2,792 on the MTF (15m/14), 3,691 on the LTF (1m/50). Not one cross event differs in bar or direction. - Trades vs an independent pure-Python simulation of the trading rules, driven by the reference's own crosses. This reproduces the live backtest's loop — entry trends gate entries, an exit-trend reversal or an opposing LTF cross closes, fills on
open[i+1], 0.09% round-trip fee, force-close on the last bar — with none of the engine's numba machinery. We then compare trade-by-trade: entry/exit bars, sides, entry/exit prices, PnL, exit reasons, and total active-time in position.
The separation thresholds in the test are not benign defaults. They are chosen to hit an awkward corner — an MTF exit threshold set higher than the matching entry threshold — which forces the "first definition of an exit trend while a position is open" branch that the reference treats as a reversal. Parity has to hold on the corner cases, not just the easy path.
Field-by-field, the trades are identical: 466 trades for the dual configuration, 211 trades for the triple, with total PnL matching to 1e-12 and every trade's fields equal to tolerance. Two implementations that share no code — a compiled vectorized engine and a naive Python loop on a third implementation's crosses — produce the same trades to the last decimal.
That is a strong result, and it is not a proof of no look-ahead. Parity says the fast engine faithfully reproduces the reference. If the reference itself leaked — if RunningCandleBuffer peeked — parity would faithfully reproduce the leak and pass. Agreement between implementations tells you they are the same, not that they are causal. For causality you need a different kind of test, one that asks the engine directly whether the past can see the future.
The shifted-future probe: the actual proof

The definition of look-ahead is operational, so test it operationally. Look-ahead means a past decision depends on future data. The contrapositive is a test you can run: if you change the future and any past decision moves, the past was reading the future. So change the future — brutally — and watch the past.
Pick a cut point j at 60% of the series (bar 51,840 of 86,400). Perturb every bar from j onward: multiply all future closes and opens by 1.05. Recompute the entire signal stack for all three timeframes on the perturbed data. Then assert that everything strictly before j is bitwise identical to the unperturbed run:
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
Not "close." Not "within tolerance." np.array_equal, with NaNs required to match NaNs — every HMA value, every HMA3 value, every cross flag, and every separation on the 51,840 past bars must be the same float. Then the same assertion on trades: every trade whose exit falls before j must be field-for-field unchanged. If a 5% shove to the future moves a single past HMA in the twelfth decimal, a past decision consulted the future, and the probe fails.
Our engine passes it — for all three timeframes, and for both the dual and triple trade simulations. Perturbing 34,560 future bars leaves 51,840 past bars and every trade that closed among them exactly as they were. That is not agreement between implementations; it is a direct demonstration that the information boundary in time holds.
A test that cannot fail proves nothing
There is a way to pass the probe above that proves nothing at all: if the perturbation were a no-op — if multiplying the future by 1.05 changed nothing anywhere — then "the past is unchanged" is trivially true and utterly uninformative. A green check on a test that cannot fail is worse than no test, because it manufactures false confidence. So the probe carries two more assertions that give it teeth.
The future must actually change. We assert that the perturbation did alter crosses somewhere in [j, n):
assert not np.array_equal(s0.cross[j:], s1.cross[j:]) # probe has teeth
Now the result is meaningful: the same 5% shove that rewrote the future left the past bit-for-bit identical. The perturbation is real, it propagates forward, and it stops dead at the cut. A one-sided leak — past reads future — would have bled backward across j; it does not.
The boundary is exactly at the current bar — not one early, not one late. A subtler failure would be an engine that is causal but stale: it ignores the current bar's running close and reacts a bar late (no leak, but a lag that live trading would not have), or one that reacts a bar early (a one-bar leak). So we perturb a single bar j (by 1.02) and assert two things at once: the past [0, j) is untouched, and hma[j] reacts immediately.
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
This pins the boundary precisely. The forming candle's running close enters the indicator at bar j with zero delay and zero foresight: bar j sees its own close instantly, and no earlier bar sees it at all. That is the exact knife-edge closed-bar semantics are supposed to sit on, and the test confirms the engine sits on it.
Here is the full gate — all 25 checks that stand between this engine and a fabricated backtest:
| Group | What each check asserts | Count |
|---|---|---|
| Indicators & crosses (×3 timeframes) | cross events exact vs RunningCandleBuffer; separation at crosses; HMA/HMA3 values (rtol 1e-9) |
9 |
| Trades (dual + triple) | trade count; field-by-field identical; total PnL to 1e-12; active-time in position | 8 |
| Shifted-future probe (dual + triple) | past signals bitwise unchanged; probe has teeth (future did change); trades before j unchanged; single-bar perturbation localized |
8 |
| Total | 25 |
The first two groups establish that the fast engine is the live reference. The third establishes that the reference is causal. You need all three: a fast engine that leaks, a leaking engine that is causal, and a causal engine that lags are three different failures, and the gate rules out each.
Why the probe is timeframe-agnostic
The elegance of the shifted-future probe is that it does not know or care where a leak would live. It never mentions timeframes, resampling, or candle boundaries. It only asks: does perturbing the future move the past? That makes it the right tool precisely for the multi-TF leak, which a shift-the-fill test misses.
Consider the naive resample-the-whole-series bug directly. If the 1h stream were built by resampling the full series up front, the "1h close" read at bar j-1000 (well inside the hour that closes after j) would be the final close of a candle whose final close depends on bars at and beyond j. Multiply the future by 1.05 and that final close changes — so the HTF indicator at j-1000 changes, the gate at j-1000 changes, and a past decision moves. The probe would light up on the HTF stream instantly, at a bar a thousand steps before the cut.
Our engine's HTF stream does not move, because at j-1000 the forming candle contributes only base_close[j-1000] — a past close — and the candle's final close is simply never consulted until the boundary passes. The probe is blind to the mechanism and still catches the bug, which is exactly what you want from a proof: it constrains the behavior (no past decision depends on future data) rather than auditing the implementation (did we index the resample correctly?). Behavior is what trades; implementation is what you hope matches it.
Backtest and live share one truth

There is one more reason this matters more than a typical backtest audit. The reference the engine is proven against — RunningCandleBuffer — is not a test fixture written to make the backtest look good. It is the live bot's own candle logic, lifted verbatim out of the tick simulator that runs in production. The closed-bar rule the probe validates is the rule the live bot executes, bar for bar.
So the parity gate does double duty. It proves the fast engine matches the reference, and because the reference is the live core, it proves the fast engine matches live. The prequel warned that a leak is the cleanest explanation for a backtest-live parity gap — the live bot is the one place you mechanically cannot peek, so a backtest that peeks diverges the moment it goes live. Here that gap is closed by construction: backtest and bot share one candle buffer, one cross rule, one definition of "known at bar i." The number the search optimizes is the number the bot can actually trade.
That is the whole point of proving no look-ahead rather than assuming it. A multi-TF search runs thousands of configurations; if the engine leaked, the search would find the configuration that exploits the leak most aggressively and hand you a fabricated winner — the failure mode the taxonomy measured at a Sharpe of 15 from pure noise. The probe is what lets you trust that the winner is real before you wire it to capital.
What the probe does and does not prove
Rigor about the test cuts both ways, so be precise about its scope. The shifted-future probe proves a single, specific property: on this data, no signal or trade decision at or before bar j depends on any bar after j — the information boundary in time holds through the indicator, cross, and trade path. That is exactly the multi-TF leak we set out to eliminate, and it is the property a code review cannot establish.
It does not prove the strategy has an edge. A perfectly causal engine can lose money honestly, and the probe is silent on that — as it should be; proving no leakage and finding a real edge are separate jobs, and conflating them is how leaked backtests get deployed. It does not cover the non-temporal biases: survivorship in the instrument, selection bias from running the probe only after the engine looked good, or a fee model that is too kind. And it does not, by itself, prove that live fills match backtest fills — slippage and latency are real gaps the probe cannot see, because it operates on the decision path, not the execution venue. What closes that gap is the separate fact that the engine's candle core is bit-identical to the live bot's.
One honest caveat about the probe's own design: it cuts at a single j (60% of the series). The property it verifies is uniform in j — nothing about bar 51,840 is special — so one cut is a fair test of a structural property, but a paranoid version would sweep j across the series. We consider one well-chosen cut plus the single-bar localization check sufficient, because a leak that hides from a 34,560-bar future perturbation but appears at some other cut would have to be a very strange bug. The point is to know the limits of your evidence, not to pretend a single test is a universal quantifier.
Takeaways
- Multi-timeframe strategies leak through the forming bar, not the fill. A decision inside an unfinished HTF period must use only that candle's running close (the latest base close), never its final close. Resample-the-whole-series backtests hand every intra-period decision up to a full HTF period of the future.
- The one-bar shift test does not reach this leak. It catches execution look-ahead; the multi-TF leak lives in how the indicator was built. You need a different probe.
- Reproduce the live rule exactly, then prove it. We rebuilt the bot's
RunningCandleBufferclosed-bar semantics as a vectorized engine and gated it behind 25 checks: crosses exact against the reference (408 / 2,792 / 3,691), trades field-by-field identical (466 dual, 211 triple), PnL to 1e-12. - Parity is necessary but not sufficient. Matching a reference proves you are the same, not that you are causal. A leaked reference reproduced faithfully still leaks.
- The shifted-future probe is the actual proof. Perturb every bar at or after
j; assert every signal and trade beforejis bitwise unchanged. If the future can move the past, you have look-ahead. - Give the probe teeth. Assert the future did change (the perturbation is not a no-op) and that a single-bar nudge reacts on that same bar (no leak, no lag). A test that cannot fail proves nothing.
- When backtest and live share one core, the proof transfers. Because the engine is bit-identical to the live bot's candle logic, no-look-ahead is a property of the live system too — and the backtest-live parity gap closes by construction.
The prequel showed how a one-line leak fabricates a Sharpe of 15. This one shows the opposite discipline: not "how leaks fool you," but how to prove, mechanically and on real data, that a specific engine does not leak. Perturb the future. If the past does not flinch, you are trading in the present.
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.