No Look-Ahead by Construction: Killing the #1 Backtest Lie
Jonny Bravo
-No Look-Ahead by Construction: Killing the #1 Backtest Lie
Every quant has shipped a strategy that looked brilliant in the backtest and bled money in production. Most of the time the culprit isn't overfitting — it's look-ahead bias: information from the future quietly seeping into a decision that was supposed to be made in the past.
It's the single most expensive bug in systematic trading, and it's almost never loud. There's no exception, no stack trace. Your Sharpe just silently inflates by 40% because a merge somewhere picked up a funding rate that wasn't actually published until three hours after the bar closed.
The usual advice is "be careful." We think that's a non-answer. Care doesn't scale, and it doesn't survive the third refactor. So we built the data layer so that look-ahead is structurally impossible — not discouraged, not linted against, but unrepresentable. This post walks through how.
Where look-ahead actually comes from
In crypto especially, your signal almost never lives on the same clock as your price. You trade on 1-hour Binance perp bars, but your edge comes from:
- 8-hour funding rates from Kucoin Futures
- Daily on-chain metrics (active addresses, exchange netflow)
- Irregular sentiment scores that arrive whenever a feed updates
Naively joining these together is a minefield. A pandas.merge on nearest timestamp will happily snap a bar at 12:00 to a funding print at 12:00 — even though that funding rate was only known at 16:00. A forward-fill done in the wrong direction copies tomorrow's value backward. A groupby().mean() over a window that includes the current bar leaks the close into the feature that's supposed to predict it.
None of these throw. All of them inflate your results.
The fix: one clock, backward-only joins, bounded by freshness
The engine treats one dataset — your base OHLCV bars — as the clock. Everything else is a layer that gets as-of-joined onto that clock, looking strictly backward, and only as far back as the data is allowed to be considered fresh.
Here's the actual join, from our feature-view builder:
inferred = infer_interval_seconds(val.index) ttl = (pd.Timedelta(seconds=layer.ttl_seconds) if layer.ttl_seconds else ttl_from_interval(inferred or lspec.interval_seconds)) aligned = asof_series(val, clock, tolerance=ttl, exact_only=(layer.join == "exact")) out[iid][layer.name] = _transform(aligned, layer.transform, layer.window).values
Three things are happening here, and each one closes a specific hole:
-
asof_series(..., clock, ...)aligns the auxiliary series onto the bar clock with a backward as-of join. For a bar at timet, it can only ever see the most recent auxiliary value with timestamp≤ t. Future rows are unreachable by construction — there's no code path that lets a bar read a value stamped after it. -
tolerance=ttlis a per-instrument freshness budget. An 8-hour funding rate carries an 8-hour TTL; if the most recent print is older than that, the value comes backNaNrather than being stale-forward-filled forever. So a feed that goes dark doesn't silently pin your signal to a week-old number — the strategy sees "no data" and can decline to trade. -
_transform(...)is trailing-only. Every transform we expose — z-score, rolling mean, pct-change, diff — is implemented withrolling(...)andshift(...), never a centered or forward window:
if kind == "zscore": m = s.rolling(window, min_periods=window).mean() sd = s.rolling(window, min_periods=window).std() return (s - m) / sd
A z-score at bar t uses only bars t-window … t. There is no parameter you can pass that makes it peek forward. The leak is designed out of the API, not patched out of a particular call site.
TTLs make "stale" a first-class state
This is the part people underestimate. In a lot of frameworks, the only two states a feature can have are "value" and "value forward-filled from whenever." That second state is where look-ahead's quieter cousin — stale-data bias — lives. You backtest as if you always had a funding rate, but in production the feed lagged and you were trading on numbers from six hours ago.
By binding every layer to a TTL derived from its own cadence, the backtest experiences exactly the same gaps production would. When a strategy reads the value, it gets None if the data is too old:
def series(self, name, iid=None): """As-of value of an auxiliary dataset at this bar (the join is invisible — point-in-time correct by construction). None if stale/absent.""" vals = self.snapshot.series.get(name) or {} return vals.get(self._ref(iid))
None is not an annoyance to be fillna'd away — it's the truth. A strategy that handles it (if self.series("funding_z") is None: return self.hold()) is a strategy that will behave the same way when a feed hiccups at 3am.
The snapshot a strategy actually sees
When your on_bar runs, it's handed a MarketSnapshot whose own docstring states the contract:
@dataclass class MarketSnapshot: """Everything a strategy may see at one bar timestamp — point-in-time, no look-ahead. bars[instrument_id] is a DataFrame of the trailing window (<= warmup_bars rows, oldest→newest, inclusive of ts). funding / series carry the as-of-aligned auxiliary values for this ts."""
The trailing window is trailing. The funding and series values are as-of-aligned for this ts. There is no field on the snapshot that contains a future bar, because the engine never puts one there. Even if you wanted to cheat, the object doesn't carry the data to cheat with.
And the execution loop reinforces it: targets you decide at bar t are filled at the open of bar t+1, with slippage and fees — never at the close you just saw. You cannot act on information at the instant you receive it, which is exactly the constraint live trading imposes.
Trust, but attest
Because the discipline is structural, we can make a claim the builder verifies on every preview. When you compose a feature view in the UI, the synchronous preview returns a leak-check block alongside the joined rows:
"leak_check": { "direction": "backward", "ttl_bounded": True, "knowledge_time_gated": as_of is not None, "interpolation": "none (ffill within TTL only)", "note": "every layer is a backward as-of join capped at its freshness TTL — no look-ahead.", }
You don't have to take our word for it, and you don't have to audit a thousand lines of join code. You see the attestation, you see the coverage and null-percentage per column, and you see exactly which bars came back empty because the upstream feed was stale. The honest result, surfaced before you've burned a single backtest.
Why this sells more than a pretty equity curve
Anyone can show you a chart that goes up and to the right. The question a serious allocator asks is: do I believe it? A backtest you can't trust is worse than no backtest — it's confidence pointed in the wrong direction.
When you can say "every auxiliary signal in this study was backward-joined onto the bar clock, bounded by its own freshness TTL, with trailing-only transforms, and the platform attested to it row by row," you're not selling a number. You're selling a number someone can stake capital on.
That's the difference between a research toy and an institutional research platform. Look-ahead isn't something we ask you to avoid. It's something we made it impossible for you to do.
Want to see it on your own data? Register a dataset in the catalog, compose a feature view, and watch the leak-check attestation come back live — no Airflow, no waiting. Start a backtest →