DEV Community

Okeke Jenny
Okeke Jenny

Posted on

Look-ahead bias hides in the bars you did not load

There is a class of backtest bug that leaves no stack trace, produces plausible numbers, and is invisible in a code review. It happens when your simulator uses a price it could not have known at the time it needed to act — and the most common instance in trading code is subtler than the usual "using tomorrow's close to decide today's trade."

This one is about the open, the high, the low and the close being four different facts, and what happens when you keep only one of them.

The setup

Take a trailing drawdown. The floor follows your peak profit upward, and if equity touches the floor you fail. Then note something about which price drives each half of that sentence:

  • The peak is set by the high — the best the account ever looked.
  • The floor is touched by the low — the worst the account looked while open.

A daily bar gives you both. A daily close gives you neither. It gives you one point, and the two quantities the rule depends on are the extremes on either side of it.

If you simulate a trailing drawdown using closes only, you are asking "what would have happened if the only price that existed was the settlement" — which is not a model of your account. It is a model of a different account.

How big is the error?

I generated 50,000 accounts with identical closing P&L, then varied only one thing: how far below the close the price travelled at its worst point during the day. The closes are byte-identical across every row. Nothing about the outcome series changes. Only the unseen part of each bar changes.

Intraday range (unseen) Pass rate, close-only rule Pass rate, true path Overstatement
0.2× 60.46% 43.75% 16.7 pp
0.5× 60.46% 41.08% 19.4 pp
1.0× 60.46% 35.66% 24.8 pp
2.0× 60.46% 11.32% 49.1 pp
4.0× 60.46% 0.48% 60.0 pp

The close-only column is 60.46% on every row. That is not a coincidence — it is the definition. A close-only simulator cannot respond to a variable it cannot see, so its estimate is a constant while the truth falls away from it.

At the reference range, the overstatement is 24.8 percentage points: 60.46% claimed against 35.66% real. That is the difference between "this rulebook is comfortably passable" and "this rulebook kills roughly two out of three accounts."

And here is the part that should worry you more than the size of the error: the error grows with the range, and the range is exactly what your data doesn't contain. You cannot compute a confidence interval on a quantity you did not sample. A close-only backtest has no way to tell you that it is off by 16 points or 60.

Minimal reproduction

The whole effect is in what the simulator is allowed to read. Both modes below run the same day objects; the only difference is two if conditions.

function simulate(days, mode, cfg) {
  // 'trailing_closed' never reads high or low. That is the entire bug.
  const closedOnly = mode === 'trailing_closed';
  let eq = cfg.start, peak = cfg.start;

  const floor = () => peak - cfg.dd;   // trailing floor

  for (const d of days) {
    if (!closedOnly) {
      const high = eq + d.high;
      peak = Math.max(peak, high);              // peak is set by the HIGH
      if (high <= floor()) return 'BREACHED';
      const low = eq + d.low;
      if (low <= floor()) return 'BREACHED';    // the LOW touches the floor
    }

    eq += d.close;                              // the close moves equity
    peak = Math.max(peak, eq);
    if (eq <= floor()) return 'BREACHED';
    if (eq - cfg.start >= cfg.target) return 'PASSED';
  }
  return 'OPEN';
}
Enter fullscreen mode Exit fullscreen mode

Note where peak is updated in the closed-only branch: only on the close. Note that the low is never consulted. Both are necessary simplifications if you only have a daily close — and both bias the result upward, because the high that raises your floor and the low that breaches it are systematically discarded.

The generator that isolates the effect:

function day(kind, rnd) {
  if (kind === 'win') {
    const close = 300 + rnd() * 500;
    return { high: close + 200 + rnd() * 700, close,
             low: close - close * (0.05 + rnd() * 0.45) };
  }
  const close = -(200 + rnd() * 400);
  return { high: 60 + rnd() * 260, close,
           low: close - (50 + rnd() * 450) };
}
Enter fullscreen mode Exit fullscreen mode

The invariant worth enforcing is high >= close >= low. My first version of this broke it: a strongly winning day could be given a low far below its own open, which meant a big up day both raised the floor enormously and breached it on the same bar. Every trailing account in the run died instantly, and the output was nonsense in a way that looked like a finding. An inconsistent bar is worse than a missing one, because you will believe it.

The general rule this is an instance of

Look-ahead bias is usually taught as "don't use information from the future." The durable version is broader:

Any quantity your strategy reacts to must be computed from data at or finer than the resolution at which the decision is made.

A trailing drawdown is a decision made intraday — it is enforced the moment the low touches the floor, not at settlement. Therefore it must be simulated with intraday extremes. A close-only model is not a lower-resolution approximation of that rule. It is a different rule, and a strictly more permissive one.

The same test applies to other common cases:

  • The daily loss limit is enforced intraday, so it needs the low. Daily closes will always under-count breaches.
  • A time limit counted in trading days can be modelled on daily bars, because the decision is made at day resolution. Not every rule needs ticks.
  • A trailing floor that locks at breakeven stops depending on the high once it locks, so the error shrinks after the lock — the bias is not uniform over the life of the account.

What to do instead

  1. If you have intraday data, use it. The gap above is 24.8 points at the reference range; there is no correction factor that recovers it from closes alone.
  2. If you only have daily bars, report the limitation as a bound, not a point estimate. A close-only result is an upper bound on the pass rate for any intraday-enforced rule. Say so.
  3. Reconstruct at least the extremes. A daily OHLC series is usually easier to obtain than intraday ticks, and it is enough to get the high and the low. That removes most of this error class for a fraction of the data cost.
  4. Assert your bar invariants. high >= max(open, close), low <= min(open, close). A one-line assertion at generation time would have caught the bug in my first version before it produced a single number.

Where this model is wrong

  • Scaling the intraday range uniformly is a modelling choice. Real ranges are heteroskedastic; they widen with volatility and are not proportional across accounts. The monotone trend in the table is robust to that, but the specific percentage points are not.
  • The other rulebooks in the run are not varied. Static drawdowns are largely insensitive to this bias — the floor doesn't move — so the 24.8pp figure is a statement about trailing rules.
  • One trade per day again. A day object with one high and one low understates how many times the low can touch the floor.
  • Single seed. The runs are reproducible but not an ensemble, so the point estimates carry unquantified sampling error.

The uncomfortable property of this bias is that it never announces itself. A close-only backtest of an intraday-enforced rule produces a clean, confident, wrong number, and the wrongness scales with the volatility of the instrument — so it will look most trustworthy exactly where it is most wrong.

I wrote up the underlying mechanics of trailing vs static drawdown separately, including how each mode's floor is defined across different firms, if you want the rulebook side rather than the simulation side.

Top comments (0)