← palnag.in Say hi →
Engineering notes · Quant research

My Backtest Was Lying.
So I Built One That Can't.

Then I audited that one too. Two months of catching my own Indian-equity backtest in the act, the system that caught every lie, and the edge that survived all of them.

By Pallav Nag · Oct 2026 · ~6 min read
TL;DR

My backtest lied about which stocks existed, about splits, about holidays, and about its own code. The good news: I'd built it to get caught. Every lie surfaced in research, before a single rupee was traded, and the edge survived all of them: +4.77pp per pick over a random portfolio (t = +13.0), compounding to 24.6% a year after costs and Indian capital-gains tax, against 10.5% for both the Nifty 500 and random picks. No live trades yet, and the signal itself stays private.

+4.77pp
Edge per pick over random
+13.0
t-stat, both halves positive
24.6%
CAGR after cost + tax
14
Defects caught before launch
Exhibit A

The ₹9 crore spreadsheet

At one point a side strategy of mine backtested at 76.9% CAGR. Compounded forward, the spreadsheet promised ₹9 crore. When a backtest says you're about to be rich, it isn't a forecast. It's a bug report.

The first bug was the universe. I'd been testing on today's Nifty 500 projected backwards, a list that only contains companies that survived. I guessed that was worth 12.7pp a year. Wrong again: measured properly it's +3.8pp a year, handed out free to anyone, including someone picking at random.

The monkeys

Beat a random portfolio, not zero

So the benchmark changed. Every idea now has to beat five random rankers drawing from the same pool, on the same dates, through the same production code, paired date by date. Five monkeys with dartboards, except the monkeys pay the same costs and taxes I do.

The monkeys went four for four:

  • The ranker from my August write-upIts "top picks" were a 19-way tie at a capped score, broken by a feature with no signal.coin flip
  • Its fixA continuous score instead of a checklist.+0.44pp · t = +1.6
  • The next twoMeasured on a universe rebuilt as of every date.−0.02pp · −0.08pp

All four retired before they touched real money. That is the control doing its job.

Rogues' gallery

Six ways the data lied

NSE's raw bhavcopy is a daily file of what printed, not a research dataset. Each of these was caught by a check built for exactly that job, then fixed for good. Some favourites:

01
The time-travelling holiday

Ask NSE for a holiday's file and it answers HTTP 200 with the previous day's data. 80 holidays had been saved as perfect copies, 159,855 rows, so 2020, 2021 and 2024 each had 261 "sessions": every weekday. Every holding period ran ~5% short.

trust the file's own date field, never the URL

02
The bank that crashed 81.5%

A blue-chip bank showed up in my live email 81.5% below its 52-week high. The real figure was 7.5%. It had split, and NSE prices are raw.

923 corporate actions detected, adjusted on read

03
Gold lost 37% a year

A gold ETF's 1:100 split had gone unnoticed since 2019. Its sibling bug, reverse splits, had one stock reading +3,118%.

bigger split factors, detection in both directions

04
A fund won the stock-picking contest

A money-market fund topped my stock ranking with a score 2.5x the next name. Funds trade in the same series as shares; only the ISIN prefix tells them apart.

INE in, INF out, at the universe layer

05
The vanishing losers

The simulator quietly dropped any stock that delisted mid-trade. Delistings follow collapses, so it was deleting bad outcomes only.

137 delistings priced, stress-tested as total losses

06
Tomorrow's prices, today

Re-running a past date read prices the scheduler had stored since. Same date, two runs, two different portfolios.

every read takes an as-of date

The audit

Then I audited the "honest" version

By mid-September the cleaned-up backtest said 29.6% CAGR. Before publishing it, I did what the system is built for and audited it, as developer, market analyst and researcher at once. It found 14 defects and took the number to 19.1%.

The scariest one wasn't in the data at all. A date join plus a pandas 3 change (pct_change stopped padding gaps by default) meant production's signal correlated only 0.92 with the one I'd validated. On the same panel they scored 23.5% vs 30.1%. The backtest had been grading a strategy I wasn't running.

Here's the part I'm proud of: the edge barely flinched. Through all 14 fixes, the per-pick edge over random went from +4.83pp to +4.39pp, still at t = +11.3. The bugs had inflated the compounding, not the signal.

Path luck

A two-day change worth +4.9pp. Allegedly.

Hold two sessions longer
+4.9pp

CAGR, at t = +6.8.

Economic change
None

Pure path luck.

Different start dates share most of their trades, so luck survives averaging and t-stats. Two of my September "wins" turned out to live inside this noise floor. Now parameters are chosen on a path-free per-pick measure, and CAGR is quoted as a mean over 15 economically equivalent configs × 45 start dates = 675 paths. By that estimator the old book was 21.8% (its single config had landed in an unlucky pocket). Re-shaping the book on the per-pick measure took it to 24.6%, ahead of the old book in 15 of 15 equivalent configs.

What survived

+4.77pp per pick over random. t = +13.0.

Positive in both halves of the sample, and simulated the boring, honest way: point-in-time universe, production code path, next-day-open fills, gap-downs filled at the open, 0.5% round trip, and STCG/LTCG plus cess netted per financial year.

Pre-tax it's 30.9%. Even the 10th-percentile path beats the index by 5 points, and the unluckiest of 675 paths still made money (+5.4% a year). The price is the ride: median max drawdown −30%, worst −54%. Blending in an ETF basket keeps the return (~23.4%) and pulls the worst drawdown in to −29%.

I'd once claimed the stock book and the ETF basket were uncorrelated, at −0.096. Measured honestly: +0.59. Another lie, caught.

What went right

The system that caught all of it

A backtest that lies is normal. One that gets caught, every time, before money moves, is the actual product. Random controls, noise twins, a path-noise null and as-of reads exposed every lie above, and they now gate every new idea. Here's what else held up:

Zero rupees lost

Every bug on this page was caught in research, before a single live trade.

The edge was real

+4.83pp per pick before the audit, +4.39pp (t = +11.3) after all 14 fixes, +4.77pp (t = +13.0) after re-shaping. Highly significant at every step.

One codebase, two jobs

Production and research share one universe function (500/500 parity) and one signal implementation. A missing signal column now raises instead of silently falling back.

It runs itself

Every trading evening, unattended: launchd, a pmset wake, caffeinate, per-step wall clocks (added after a Gemini call hung for 27 hours) and re-runs that are safe to fire twice.

Fast enough to be thorough

3.53M rows in SQLite. Swapping 1.37M DataFrame.iloc calls for NumPy made the research loop 8.5x faster, bit-identical across ~92,000 simulated trades.

Money code tested like money

Every allocator bug reproduced by a failing test before the fix, like one that proposed ₹29,797 of orders against ₹5,910 of cash.

Scoreboard

Most ideas die. That's the job.

250+ specifications tested; roughly 1 in 70 survived. That isn't a bad hit rate, it's the published one: Hou, Xue and Zhang found 64% of 447 published anomalies insignificant. Every result, pass or fail, is logged with its number, so no idea gets paid for twice.

Prefer deleting a signal to adding one

This project has shipped more value by removing things than by adding them. The enemy isn't ignorance. It's plausibility.

Still true, stated plainly: 4.3 years of mostly one bull market. Zero live trades. The benchmark is the price index, so it's missing ~1.2 to 1.5pp a year of dividends. And a passive ETF basket did 21.5% (untaxed) over the same window with a −13.6% drawdown. Beating random stock-picking is proven; beating a lazy ETF basket after tax is the next thing to prove, and live trading is how I'll prove it.
Want to talk shop → say hi