edgekit

Causality

A backtest is only as trustworthy as its arrow of time. If a strategy acts on information a bar has not finished revealing, the number it prints is fiction. edgekit treats no-look-ahead not as a coding convention to remember, but as a tested property the CI enforces on every indicator.

The +34% mirage
This library exists partly because of one scar. A research script reported a +34% improvement from an "H+M" filter. It was ~90% look-ahead: the strategy read the same day's daily bar — a bar that had not yet closed at the moment it claimed to act — so it was effectively trading on the future. Re-run causally, the edge evaporated. Every design decision below is a reaction to that.

What look-ahead bias is#

Look-ahead bias is a strategy "adapted to a filtration it should not see": the decision at bar i uses data that would not have been available until bar i closed (or later). The classic forms:

  • Acting on the current bar's close. Computing a signal from close[i] and then "entering at close[i]" — but you only know the close once the bar is over, i.e. at the open of i+1.
  • Reading a not-yet-closed higher timeframe bar. The +34% mirage: an intraday strategy consulting today's daily bar, whose high/low/close are still forming.
  • Un-lagged indicators. A rolling max/mean/z-score at index i includes bar i itself; acting on it at i peeks.
  • Optimistic fills. Assuming you got filled at a level the market gapped straight through.

edgekit's rule: indicators are unlagged; the caller lags#

Every indicator in edgekit.indicators returns values aligned so that index position i is the indicator computed through bar i, inclusive — and it is deliberately notlagged. The lag is the caller's explicit, visible responsibility, done with the single most important primitive in the library:

core.py (excerpt)
def lag(x, k: int = 1):
    """Causal shift: a value known only after bar close is pushed forward k bars.
    Every indicator MUST be lagged before a strategy may act on it, or the
    backtest reads the future. Leading positions become NaN."""
    s = pd.Series(np.asarray(x, dtype=float))
    return s.shift(k).to_numpy() if not isinstance(x, pd.Series) else x.shift(k)

Why keep the lag at the call site instead of baking it into each indicator? Because it makes the causality decision visible and testable. When you read a strategy and see ek.lag(upper, 1), you can see exactly what information the entry is allowed to use. A lag hidden inside donchian() would be invisible, un-auditable, and — as the repo learned the hard way — occasionally forgotten.

The contract, stated once
Acting on bar i may only use data through bar i-1. Indicators come back through i; you lag(x, 1) them so the value the strategy reads at i is the one that was fully known at the close of i-1.

The causal pattern in practice#

A Donchian breakout is the canonical example. The channel is computed unlagged, then lagged one bar before the entry condition looks at it, and the fill happens gap-aware at the next bar's open:

causal_donchian.py
import edgekit as ek

def prepare(bars):
    upper, lower = ek.indicators.donchian(bars.high, bars.low, n=20)
    return {
        # lag by 1: the channel a decision at bar i may see is the one known at i-1
        "upper": ek.lag(upper, 1),
        "lower": ek.lag(lower, 1),
        "atr":   ek.lag(ek.indicators.atr(bars.high, bars.low, bars.close, 20), 1),
    }

def entry(bars, P, i):
    # act at bar i using ONLY lagged (through i-1) information
    prev_close = bars["close"].iloc[i - 1]
    if prev_close > P["upper"][i]:                 # yesterday broke the prior 20-bar high
        return ek.engine.EntryIntent(direction=1, level=P["upper"][i],
                                     stop_dist=2 * P["atr"][i])
    return None

Notice there is no reference to bars.close.iloc[i] in the decision — that close has not happened yet at the moment the strategy fires. The engine then fills the intent on the next executable price.

Gap-aware fills#

Causality is not only about indicators; it is about assuming a realistic fill. If a long's breakout level is 100 but the market gaps open to 103, you did not get filled at 100 — you got the open. edgekit's engine and its two fill helpers encode this pessimism:

engine.py (excerpt)
def fill_entry(level, open_price, direction):
    """Gap-aware entry: a long gapping up fills at the open, not the stale level."""
    return max(level, open_price) if direction > 0 else min(level, open_price)

def fill_stop(stop, open_price, direction):
    """Pessimistic stop: a gap-through the stop fills at the worse of stop/open."""
    return min(stop, open_price) if direction > 0 else max(stop, open_price)

Inside run_bar_loop the entry fill is max(intent.level, open[i]) for longs and min(...) for shorts — you always get the worse of your intended level and reality. In the prop-firm bracket engine, a bar that touches both stop and target in the same candle is booked as the stop (pessimistic tie-break). Optimism about fills is just a slower form of look-ahead, and it is refused everywhere.

The causality property test — the load-bearing guarantee#

Rules are easy to write and easy to forget. So edgekit does not rely on the rule being followed — it proves it, mechanically, in CI. The property test perturbs a future bar and asserts the past does not move. If any indicator leaked future information, the value at some earlier index would change when the last bar is corrupted, and the test fails:

tests/test_foundation.py (the load-bearing test)
@pytest.mark.parametrize("fn", [
    lambda b: ind.atr(b.high, b.low, b.close, 20),
    lambda b: ind.adx(b.high, b.low, b.close, 14),
    lambda b: ind.rsi(b.close, 14),
    lambda b: ind.sma(b.close, 50),
    lambda b: ind.donchian(b.high, b.low, 20)[0],
    lambda b: ind.hawkes_vol_expansion(b.high, b.low, b.close).astype(float),
])
def test_indicator_is_causal(fn):
    """An indicator at bar i must not change when a FUTURE bar is perturbed."""
    b = _bars()
    full = np.asarray(fn(b), float)
    b2 = b.copy()
    b2.iloc[-1, :] = b2.iloc[-1, :] * 1.5     # perturb ONLY the final bar
    perturbed = np.asarray(fn(b2), float)
    a, c = full[:-1], perturbed[:-1]          # everything before the last bar...
    mask = np.isfinite(a) & np.isfinite(c)
    assert np.allclose(a[mask], c[mask]), "indicator leaked future information"

This is the correctness test the whole edifice rests on. An indicator that used any information from bar i to compute its value at an earlier bar j < i would fail it. Because it runs over the full indicator battery on every commit, a refactor that quietly introduces a centered window, a forward fill, or a stray un-lagged reference cannot merge. Note the test intentionally corrupts only the last bar and checks all prior positions — the sharpest possible probe for a leak.

!The classic mistakes, avoided by construction
  • Signalling on close[i] and filling at close[i] — you don't know the close until i+1's open. Fill on the next open instead.
  • Consulting a higher-timeframe bar that has not closed. Resample with the causal label="left"/closed="left" convention (see the OHLC contract) so a bar's timestamp only exposes closed bars.
  • Forgetting to lag() an indicator. The engine acts on what you give it — an un-lagged array is look-ahead.
  • Filling at a level the market gapped through. Use fill_entry/fill_stop, or the engine that already applies them.

Why this is step one of the gauntlet#

The validation gauntletopens with the causality audit for a reason: every downstream test — permutation, walk-forward, cost stress — is measuring a number, and if that number was produced by reading the future, everything after it is measuring a ghost. Causality is enforced first, by construction and by property test, so the rest of the gauntlet is asking "is this real?" of a number that at least belongs to a real, tradeable timeline.

See also#