4.4 Backtesting methodology
Motivation
A backtest is a simulation of what a rule would have earned. It is a program, and like any program it has bugs. Unlike most bugs, backtest bugs are biased: almost all of them make the result look better. This section defines what a correct backtest is, lists the ways it fails, and gives tests that catch the failures.
4.4.1Event-driven and vectorised designs#
Fix the timing convention once. The strategy observes bar after its close, computes target weight , and the order fills at the open of . (Filling at the close of is possible in India through the closing auction session introduced in 2026, see M01, but a close fill must then use a signal computed before the close.)
Algorithm 4.1event-driven backtest; run_backtest in the code
State: cash , shares , pending target . For :
- At the open: equity . Desired shares . Trade at fill price , where is slippage. Pay commission fill. Update and .
- At the close: equity .
- Call the strategy on bars only, and store its target for the next open.
Because step 3 receives a truncated history, look-ahead is impossible by construction, whatever the strategy code does. That is the main argument for event-driven design. Its cost is speed: a Python loop over bars, calling the strategy each time.
A vectorised backtest computes all signals at once on full arrays and multiplies by returns. It is fast and convenient for parameter sweeps, and it is correct if and only if the weights are lagged properly.
Proposition 4.8when vectorised equals event-driven
Suppose the event-driven engine rebalances to the target weight at every open and costs are zero. Let (with ) be the weight held over bar . Then
With proportional cost per side, the open equity is multiplied in addition by , where , , is the weight to which the previous position has drifted. This is exact to first order in .
Proof
With zero costs, after rebalancing at open the engine holds shares and cash . At the next open, . Rebalancing at open is self-financing, so is unchanged and the new holding is shares. Then . With costs, the traded value at open is , since . The cost is deducted. The engine sizes the new position from pre-cost equity, so the post-cost weight is ; that discrepancy is times the next return.∎
The code verifies (4.8) to with zero costs and to relative with costs. The vectorised formula is correct because of the lag . Drop that lag and you earn the return of the bar on which the signal was computed. The test test_unshifted_vectorised_is_lookahead shows a rule "long if today's close is above today's open" turning a random walk into a fortune when the shift is omitted.
The engine also has a second mode, rebalance="on_change": it trades only when the target changes and otherwise lets the weight drift. That is how a swing trader actually holds a position, and it has much lower turnover than constant-weight rebalancing.
4.4.2Look-ahead bias: a catalogue#
Look-ahead bias is using information at time that was not available at . Ways it enters:
- Same-bar execution. The signal uses the close of and the position earns the return from close to close . Fix: lag the position by one bar, as in (4.8).
- Centred or forward windows.
rolling(..., center=True),shift(-1), or a swing high confirmed bars later but marked on the day it occurred (4.1.2, 4.2.4). - Full-sample normalisation. Z-scoring a feature with the full-sample mean and standard deviation, or choosing a threshold such as "the top decile of RVOL" from the whole history. Use expanding or rolling statistics.
- Restated or late fundamentals. A quarterly result is known only after its announcement date and time. Under SEBI's listing regulations (LODR Regulation 33), Indian listed companies file quarterly results within 45 days of quarter end and annual results within 60 days; check the current regulation before relying on these deadlines. Databases often store the restated number against the quarter-end date. Use announcement-dated, as-first-reported data.
- Index membership. Using today's Nifty 500 list for a 2015 backtest. Use point-in-time constituents from NSE index-change press releases.
- Corporate-action adjustment of price levels. Adjusted prices are correct for returns. A rule on price levels (a minimum price of ₹100, or "₹1,000 is resistance") changes when a later split rescales the past.
- Time zones. The US cash market closes around 01:30–02:30 IST on the next Indian calendar day. Using the S&P 500 close dated to trade Nifty at its close on day uses information from 10–11 hours in the future.
- Smoothed state estimates. Regime probabilities from a Kalman or Hamilton smoother use the whole sample. Use filtered probabilities (M13 · 13.5).
- Parameter selection on the full sample. Choosing the best moving-average length using all data and then reporting the backtest over the same data. This is look-ahead in the choice of model, and 4.6 treats it.
A necessary test If a strategy is causal, perturbing any data after date leaves all targets and equity up to unchanged. The test test_no_lookahead_future_perturbation multiplies all prices after by a random path and asserts that targets and equity through are bit-identical. Run this test on every new strategy. It is necessary, not sufficient: it cannot detect look-ahead hidden inside your data, such as restated fundamentals or survivor lists, because perturbing prices does not change those inputs.
4.4.3Survivorship bias#
A survivor-biased universe contains only assets that still exist (or are still in an index) at the end of the sample. Firms that were delisted, merged or dropped from the index are missing, and they are disproportionately the poor performers.
Proposition 4.9survivorship bias in a simple model
Let a stock's annual return be and suppose it survives the year iff . Then
Proof
with standard normal. Now , and .∎
Worked example 4.4
Small caps with , , and removal from the universe after a fall of more than 50% in a year (). Then , survival probability 93.9%, and the survivors' mean return is 17.1%: a bias of 5.1 percentage points a year from a 6% annual attrition rate. Real delisting is not a clean threshold, and the bias compounds across years. For breakout systems the bias is worse, because a stock that broke out and then collapsed out of the index is exactly the losing trade the backtest omits.
4.4.4Costs and slippage#
M01 built the full cost algebra. For research, collapse it into a per-side rate and an assumed slippage .
Proposition 4.10cost drag and breakeven cost
Let a strategy have gross mean return and volatility per year, and turnover per year (total traded value, buys plus sells, divided by average capital; this is twice the one-way turnover, the lesser of purchases or sales over average assets, that fund factsheets usually report). With per-side cost (including slippage), the net mean is approximately , the net Sharpe ratio is , and the breakeven cost is
Proof
Each period's net return is the gross return minus times traded value over equity (first order in , as in Proposition 4.8). Summing over a year gives . Costs are deterministic given turnover, so to first order they do not change . Set for .∎
Worked example 4.5
A breakout system makes 25 round trips a year, each with the full portfolio: . At 11.9 bp per side (Worked example 4.3) the drag is a year. Add 10 bp per side of slippage and it is 10.95%. A gross 18% a year becomes about 7%. The breakeven cost is bp per side, a margin that mid-cap slippage alone can consume. Measure turnover in every backtest and report alongside the Sharpe ratio.
Slippage has two parts: half the bid–ask spread, and market impact, which grows roughly with the square root of order size relative to daily volume (M05 derives the Almgren–Chriss model). For stocks with price bands, also model the no-fill day: a sell stop on a stock locked at its lower band does not execute.
4.4.5Walk-forward analysis#
Algorithm 4.2walk-forward
Choose a training length and test length . For : fit or select parameters on ; freeze them; run the strategy on and record its out-of-sample returns. Concatenate the test segments into one out-of-sample equity curve. Rolling walk-forward keeps fixed. Anchored walk-forward starts every training window at the beginning of the data.
Example: daily Nifty 500 data from January 2010 to December 2025, years and year, gives 13 test years (2013–2025), each traded with parameters chosen only from the three preceding years.
Practitioner notes on walk-forward:
- It reduces, but does not remove, selection bias. If you try 50 versions of the walk-forward procedure (window lengths, parameter grids, rules) and report the best stitched curve, you are back to 4.6.
- Labels that span time (a 20-day forward return) overlap the boundary between train and test. Purge training samples whose label window overlaps the test window, and embargo a gap after it. M13 · 13.6 gives the purged k-fold construction.
- The short test windows make the out-of-sample Sharpe ratio noisy. Use 4.5 for error bars on the stitched curve, not on each window.
4.4.6A backtest checklist#
- Point-in-time universe (including delisted names) and point-in-time fundamentals.
- Signals computed on bars ; fills at the next open, or at a modelled price within the next bar; stops filled at on gaps.
- Indian costs per side from M01 (STT, exchange, stamp, GST, DP) plus slippage, with a sensitivity table at 2× costs.
- Turnover, breakeven cost, exposure (fraction of time invested), and number of trades reported.
- The future-perturbation test passes.
- The full count of trials is recorded in a research log (4.6.7).
- Error bars on the Sharpe ratio (4.5) and a deflated Sharpe ratio (4.6.3).