02 — Leakage catalogue¶
Every leak below has been shipped to production by someone competent. They are ordered roughly by how often they occur.
Run python -m nullres audit --config <cfg> to check leaks 1, 2, 3, 3b and 5
mechanically. Leak 4 is prevented by construction rather than checked, leak 6 is
handled by the run ledger, and leak 7 cannot be automated at all. The table at
the bottom of this file maps each check to what it catches.
1. The label is derivable from a feature¶
The original baseline's bug. The label was "did this bar rise?" and the
feature set contained ret_1, which is that quantity. The model read the
answer off its own input and scored 0.9986 out of sample.
def label_leaky(df):
return (np.log(df["close"]).diff() > 0).astype(int) # same bar
f["ret_1"] = np.log(df["close"]).diff() # ...is this
Walk-forward validation cannot catch this. The leak is inside every fold equally, so every fold agrees, and agreement reads as robustness.
Detection: audit.check_label_leakage scores each feature's AUC against the
label individually. Real financial features land at 0.50–0.55. Anything above
0.65 is a leak, not a discovery. On the baseline's label, ret_1 scores ~1.0.
2. Normalising with statistics from the whole sample¶
f["z"] = (close - close.mean()) / close.std() # mean of the FUTURE too
scaler.fit(X) # before splitting
Small leak, real leak. The 2024 mean tells the 2021 model where prices were
headed. In nullres, scaling for linear models lives inside a sklearn.Pipeline
so it is refitted per fold — see models/classifier.py.
Detection: point-in-time check. Truncating the history changes the mean, which changes the feature, which fails the comparison.
3. Backfilling, or joining a lower frequency badly¶
daily = close.resample("D").last()
f["x"] = daily.reindex(hourly.index, method="bfill") # every hour of Monday
# gets Tuesday's close
ffill is usually right; bfill is essentially always wrong. And even ffill
needs a .shift(1) on the daily series — at 09:00 Monday, Monday's daily close
has not happened yet.
Detection: point-in-time check. tests/test_audit.py contains both the
broken and the correct version of this join.
3b. Joining an EXTERNAL series on the wrong instant¶
The same class of bug, but sharper, and it arrived with funding rates and open
interest. Every other feature in this repo derives from the bars themselves, so
a lookahead had to be a visible .shift(-1). Joining a separate time series
fails differently — a join direction or a one-interval misalignment, neither
of which reads as wrong.
Three things have to be right at once:
- The bar's close, not its open. A bar indexed at
Tcovers[T, T+interval)and closes atT+interval. Joining onTsilently discards a full bar of information; joining on anything pastT+intervalis lookahead. - Strictly before. A funding settlement stamped exactly at the close is
simultaneous with it.
allow_exact_matches=False. - The auxiliary frame must be clipped to the bar range. This is the
non-obvious one.
check_point_in_timetruncates the bars and recomputes — but if the funding frame still holds future rows, adirection="forward"join returns the same value either way and the audit passes while the feature reads tomorrow's funding. Clipping makes truncating the bars also truncate the auxiliary data, which restores the check.
tests/test_derivatives.py::test_forward_join_is_caught_by_the_audit injects
exactly that bug and asserts it is caught. Without point 3 it is not.
4. Overlapping labels crossing the train/test boundary¶
If a label at bar t resolves at t+500, and the test window starts at
t+100, that training row already contains 400 bars of test-period outcome.
A fixed purge constant only works when every label has the same horizon — which
stops being true the moment you use barriers, since a label resolves when a
barrier is touched, not on a schedule. So labels/ returns a t_end column
recording each label's actual resolution bar, and validation/splits.py purges
on that:
The embargo drops training rows that merely end near the boundary, because
serial correlation makes a row from five bars before the split nearly identical
to one inside it.
Detection: tests/test_splits.py asserts t_end[train].max() < test.min()
for every fold, and that longer horizons purge strictly more rows.
5. Survivorship and delisting¶
Backtesting today's top 100 coins over 2020–2025 tests the strategy "buy things that will still exist and be popular in 2025". The losers were deleted from your universe, so your strategy cannot lose on them.
Partly detectable, contrary to what this file used to claim. The dominant failure mode is mechanical, and it is this:
A multi-symbol universe, spanning a period that killed assets, which contains none of them, was filtered by survival.
audit.check_survivorship tests exactly that. It fails a universe with no
delisted members, and fails one that drops most of the symbols which were
actually trading at the sample start — because "they are not around any more"
is not a selection criterion.
Two supporting pieces make it possible:
data.universe.universe_as_of(month)enumerates the archive and asks each symbol "did you have data that month". A coin that listed in 2023 fails; a coin that died in 2022 passes, and belongs in the sample. Writing the list by hand instead is hindsight — you will recall the survivors.data.universe.delisted_from_cacheidentifies symbols whose archive stops early, offline, from local files.
The repo's own universe is built this way. The archive lists 138 USDT perps
trading in 2021-12; two of them (BTCDOMUSDT, DEFIUSDT) are index products
rather than assets and are excluded, leaving 136. Of those, 16 have
archives that stop before the sample ends — they were delisted, and they stay in
the universe. Fewer than that reach the traded book, because the top-40
liquidity screen is applied afterwards; nullres xsec prints which ones and the
date each stopped trading.
What it still cannot see is whether you picked the winners among the survivors. That is hindsight, entry 7 below, and no check will ever catch it.
A note on the n/a verdict. For a single-symbol backtest this check
reports "not applicable" rather than PASS. A green tick would claim the risk
was ruled out when it was never examined, which is the same species of false
assurance the audit exists to prevent.
6. Choosing hyperparameters on the test set¶
Running 25 threshold combinations and reporting the best one is not out-of-sample, no matter how correct your splits are. The test set became your training set the second you used it to choose.
prints the whole surface rather than the maximum, and tells you what to look for: a real edge degrades smoothly as parameters move. A lone positive cell surrounded by negatives is a fitting artefact.
nullres robust makes that judgement quantitative. It measures how often the
sign changes between adjacent grid cells and tests that count against
Binomial(pairs, 2p(1-p)) — the distribution you would get by scattering the
same number of positive cells at random. If the observed count is not
significantly below that rate, the arrangement carries no information, however
good the best cell looks. When the grid is so lopsided that even a perfectly
smooth arrangement would not be significant, the test says so rather than
guessing.
That comparison used to be a constant (0.8 x expected) standing in for a
significance level. It gave the same answers on the grids in this repo, but a
tunable constant deciding whether results live is precisely the thing the rest
of this file is about.
If you must select, metrics.deflated_sharpe subtracts the Sharpe you would
expect to reach by luck given n_trials. Searching 100 variants on pure noise
yields a best-of-100 Sharpe around 0.6 for free.
The hard part is counting honestly. n_trials was originally the number of
strategies in the current run — six — for a project whose ledger now records 220
parameter combinations. That understated the penalty on every result published
here. Deflating the best one (Sharpe 1.80) against six trials leaves 1.07;
against the true count it leaves 0.22, which is positive and far too
small to trade.
Counting honestly cuts both ways. Summing every recorded run made the correction a function of how often commands were executed rather than how many hypotheses were tested — repeating one experiment five times deflated every result without testing anything new. Each distinct (config, command) pair now contributes once.
The count now comes from the run ledger (runs/), which records how many
variants each run evaluated — a 25-cell sweep is 25 trials, not one — plus a
prior_trials field for exposure predating the ledger. It is deliberately
global rather than per-config: a researcher who would have published whichever
of six configs worked has tried six, not one.
7. Hindsight in the research process itself¶
The subtlest one, and the only one with no code fix. You know BTC went up 2020–2025. You know March 2020 and FTX happened. Every choice you make — the symbol, the date range, which result made you keep going — is informed by outcomes you already know.
There is exactly one honest test left, and it is the one nobody wants to run: forward paper trading on bars that did not exist when you wrote the code. Everything before that is a hypothesis.
What the audit actually covers¶
| check | catches |
|---|---|
check_point_in_time |
leaks 2, 3, 3b — any future reference in features |
check_label_leakage |
leak 1 |
check_shuffled_label |
side channels: permuted labels must be unlearnable |
check_null_data |
engine/split bugs: a random walk must yield no edge |
check_survivorship |
leak 5 — a universe with no corpses in it |
purged splits + t_end |
leak 4, enforced by construction rather than checked |
ledger-derived n_trials |
leak 6 — deflates against every variant tried |
Six of the seven are now mechanical. Only entry 7 — hindsight in the research process — is left entirely to you, and it is the one that no test suite will ever reach. Everything above tells you whether your measurements are sound. None of it tells you whether the question was chosen after you already knew the answer.