Skip to content

Build1 publisher3 min readPublished

Lookahead bias passes code review because it improves the metrics. Test the clock, not the output

A developer building a pairs-trading backtest found two of his own plans disagreeing on how many times to shift a series. His fix was a test that fires a price spike into the future.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

Illustration accompanying Lookahead bias passes code review because it improves the metrics. Test the clock, not the output
Generated illustration

What happened

  • The scariest bug in a backtest is not a crash but accidentally using tomorrow's price; the worst kind of bug is the one that makes you feel good, because if anything the numbers get better.
  • Lookahead bias is letting even a sliver of tomorrow's close leak into today's math, which turns the strategy into a prophet.
  • With lookahead bias the backtest numbers glow, and then the strategy melts on contact when shipped live.
  • The code never throws a single exception when lookahead bias is present.
  • The no-cheating implementation comes down to sliding a series over by one (shift(1) in pandas), reproducing the real-world lag in which today's decision shows up in tomorrow's trade.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

A developer writing a backtest engine for a pairs-trading strategy discovered that his own planning documents disagreed about how many times to lag a series, and instead of settling it by eyeballing the diff he wrote tests that assert on time ordering [s1c6][s1c7][s1c11]. That matters because the bug he was hunting does not crash, does not raise an exception, and tends to make the numbers look better [s1c1][s1c4].

The failure mode is lookahead bias: letting even a sliver of tomorrow's close into today's math, at which point the strategy behaves like a prophet, the backtest numbers glow, and it melts on contact with live trading [s1c2][s1c3]. The mechanical fix is small. In pandas it is `shift(1)`, reproducing the real-world lag in which today's decision fills tomorrow [s1c5].

Small enough to get wrong twice. According to the writeup, the overall plan said to shift the hedge ratio beta by one bar and shift the position too so it fills the next day, while a later handoff note from the same author asked whether that was a double delay [s1c6][s1c7]. It was: shift for beta, shift again for the position, and the engine is two days late [s1c8].

This is the part worth internalising. Too few shifts is cheating and too many is trading a day late; both quietly move the numbers and neither raises an error [s1c9]. Cheating inflates returns, over-delay deflates them, and what lands on your screen either way is a plausible-looking Sharpe ratio that sails through review [s1c10]. The author says plainly that he is not confident he would catch one extra `shift(1)` in code review, so he stopped trying to [s1c11].

What he did instead has two parts. First, pin the rule to one sentence: the realized PnL of bar t depends only on information up to the close of day t-1 [s1c12]. Signal math (beta, spread, z-score, the buy/sell decision) may use everything up to today's close, and exactly one shift lives in the PnL calculation [s1c13]. Collecting the lag in a single place makes a double delay structurally impossible [s1c14]. Second, he stopped trusting the docstring, on the grounds that a policy written in prose is a promise and promises get broken by the author three months later [s1c15].

The tests are the interesting artefact. Test one takes the same price series twice, injects a single giant spike (price +500 percent) on a future day into one copy only, computes beta and z-score for both, and asserts that every value before the spike is exactly identical using numpy's `array_equal`, not approximately equal [s1c16]. If the beta window is reaching forward, the spike bleeds backwards and the comparison breaks [s1c17]. He also asserts that beta on the spike day does change, which is what catches an extra shift added later out of kindness [s1c18]. Test two builds synthetic data where the z-score crosses the entry threshold on day 5 and requires PnL to be zero on day 5 and start on day 6 [s1c19]. Taken together, the two assertions leave exactly one shift as the only configuration that passes: fewer and PnL appears on day 5, more and the spike day stops moving [s1d1].

Watch the failure that came with it. A sanity test asserting that a mean-reverting series should turn a profit failed on first run, because with only 10 trades the RNG seed happened to land on the loss side [s1c20]. Thirty minutes went into suspecting the engine before the test data was suspected, and the fix was swapping in synthetic data that reliably profits [s1c21][s1c22]. Assertions about time ordering are deterministic; assertions about economic outcomes are a coin flip you have to tune. Green tests here buy one specific claim, which is that there is no time-direction cheating after a refactor [s1c23]. They do not buy a working strategy.

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories