In June I ran a backtest of a 10-K text-change signal, the Lazy Prices idea: companies whose annual reports change a lot tend to do worse afterwards. S&P 500 names, monthly rebalancing, net of costs. It looked good. In October, before taking it further, I tried to reproduce the headline from the stored code and data, and 81 of 82 months came out different.
The cause was one function. To compute a month's return, it looked up the first price bar on or after the start of the window and the first one on or after its end, with no limit on how far away that bar could be. For a ticker with no price history around the target, the lookup quietly returned a bar from months or years later.
What it did to the numbers
- 719 of 3,282 name-months (21.9%), across 56 tickers, were priced from bars outside their month. AAPL, AMGN and COST were among them.
- For 34 of those tickers, the price table only started in December 2025. So the June 2026 run booked 2026 prices into months from 2019 to 2025.
- Every number from that backtest is void: the headline Sharpe, the information coefficient, the out-of-sample check. Not one line of code raised an error.
Why nothing complained
The lookup did exactly what it was written to do. A missing bar isn't an exception, it's a gap, and "the next available bar" fills it. The returns looked like returns: no NaNs, no zeros, nothing extreme enough to stand out in a monthly series. It only showed when I re-ran the same code three months later. The price table had gained history in the meantime, the panel no longer matched, and tracing the mismatch led to the lookup.
It's the same family of mistake as the one in my earlier post about earnings release times: the backtest used information from after the moment it claims to act. There it was the release session. Here it was the price bar itself.
The fix
Bound every lookup. The corrected function only takes entry and exit bars within 7 calendar days of their targets, drops the name-month otherwise, and reports how many it dropped. A missing price should cost you a data point, not borrow one from the future.
If you use pandas, merge_asof has the same trap. Without tolerance, it joins the nearest bar at any distance.
Check your own backtest
I added this check to earnings-session, the small tool from the first post. Version 0.2.0 has a bars command that runs offline:
pip install earnings-session
earnings-session bars trades.csv --prices prices.csv -o checked.csv
trades.csv needs ticker and entry_date, and optionally exit_date and return. prices.csv is the price table your backtest read, with ticker and date. For each trade it finds the bar a "first bar on or after" rule would take, measures how far that bar sits from its target in NYSE trading days, and flags the row:
-
history_break: the bar is more than 250 trading days away, so it belongs to another stretch of history, maybe another company on a reused ticker. -
entry_gap/exit_gap: the bar is more than--max-gaptrading days away (default 0). -
no_bar: the ticker isn't in the price table at all. -
early_bar: your trade file records the bar the backtest used, and it's before the entry date, so the trade was priced before the day it claims.
The summary also splits the returns. If flagged rows are a minority but carry most of the result, the backtest is measuring its lookup, not the signal. --fail-on-flag exits 1, so the check can run in CI next to your tests.
A checklist
- Put a distance limit on every price lookup, and count what the limit drops.
- Before trusting a result, measure the distance between each target date and the bar actually used.
- Compare the returns of misdated rows with the rest. A misdated minority carrying the result is the fingerprint.
- Check against the price table the backtest actually read. A table that was filled in later hides the problem.
- Watch tickers whose history starts late or has long gaps: new listings, delistings, reused tickers.
I'm re-running the corrected backtest under the thresholds I registered before touching the data. Whatever it shows, I'll take the new number over the old one.
Update, 2026-10-28: the corrected run is done, and it failed the gate I registered. Long-short net Sharpe is −0.07 (t −0.18) and the monthly IC is −0.008. The long-only version made money mostly from market beta and trailed SPY by about 4.8% a year. The old headline Sharpe of 1.54 came from the misdated rows.
Questions are welcome in the comments. Happy to share what I know.
Top comments (0)