Vectorised backtesting of Indian equities in pandas, done correctly
The short answer
A vectorised backtest turns a whole price history into strategy returns with array arithmetic and no Python loop over rows: compute a signal from prices, convert it to a target position, then set strategy return = position.shift(1) × asset return and compound. The shift(1) is the load-bearing line. A signal read off today's close can only be traded from the next bar, so the position that earns today's return must have been decided yesterday. Drop the shift and the backtest quietly trades on information it never had, which inflates every number that follows. The other four ways it lies are corporate actions, a survivor-only universe, ignored costs, and index misalignment.
Pandas makes backtesting deceptively easy and honest backtesting genuinely hard. The arithmetic below is ten lines; the correctness around it is the whole job. This walkthrough builds a daily equity backtest the vectorised way and foregrounds the traps that make a good-looking equity curve worthless. The examples use generic column names and a placeholder universe so the pattern is what you take away, not any particular instrument. Where a number appears it is arithmetic you can reproduce, never a claimed result.
1. Loading Indian daily data into a price panel
The National Stock Exchange publishes an end-of-day bhavcopy: a daily CSV with one row per traded security carrying the symbol, the series (regular equity is series EQ), and the open, high, low and close. A backtest wants the opposite shape. You want a price panel: a DataFrame indexed by date, one column per instrument, each cell a close. Getting from stacked daily files to a clean panel is where the first quiet bugs enter.
import pandas as pd
import numpy as np
# Read a folder of daily bhavcopy CSVs into one long, tidy frame.
# Each file is one trading day; keep only regular equity (series EQ).
frames = []
for path in sorted(bhavcopy_paths): # e.g. a list of daily CSVs
day = pd.read_csv(path, parse_dates=["DATE"])
day = day[day["SERIES"] == "EQ"]
frames.append(day[["DATE", "SYMBOL", "CLOSE"]])
long = pd.concat(frames, ignore_index=True)
# Pivot to a price panel: dates down the index, instruments across columns.
prices = (long
.pivot(index="DATE", columns="SYMBOL", values="CLOSE")
.sort_index())
prices.index = pd.DatetimeIndex(prices.index) # ensure a real datetime index
prices = prices.asfreq("B") # business-day calendar
Two decisions are hiding in those last two lines. A DatetimeIndex is what lets pandas align two series by date later; without it, joins fall back to positional and mask errors. And asfreq("B") reindexes onto a business-day calendar, which surfaces missing rows as explicit NaN instead of letting them vanish. Indian markets also close on exchange holidays that are not weekends, so a business-day calendar still contains days the market never traded. Those show up as all-NaN rows, and how you treat them, drop, forward-fill or leave as gaps, is a modelling choice you should make on purpose rather than inherit from a default.
NaN so returns on those days are genuinely absent, not fake zeros.2. The vectorised backtest pattern
With a clean panel, the backtest itself is a short chain of array operations. The idiom is the same whether you run one instrument or a whole panel: prices become returns, a rule becomes a signal, the signal becomes a position, the position earns the next bar's return, and returns compound into equity. No row loop appears anywhere, which is what "vectorised" means and why it runs in milliseconds on years of data.
# A single-instrument backtest on a close series `px` (a column of `prices`).
returns = px.pct_change() # asset returns, one per bar
fast = px.rolling(20).mean() # a rule: fast over slow
slow = px.rolling(50).mean()
signal = (fast > slow).astype(float) # 1.0 = long, 0.0 = flat
position = signal.shift(1) # act on YESTERDAY's signal
strat = position * returns # strategy return per bar
equity = (1.0 + strat.fillna(0)).cumprod() # compounded equity curve
Read the last three lines carefully, because they are the entire method. position = signal.shift(1) says the exposure you hold today was decided at yesterday's close. strat = position * returns earns today's asset return on that already-decided position. cumprod compounds. The fillna(0) is deliberately placed on the strategy return, not on the position, so days with no position simply earn nothing rather than fabricating a value. On a full panel the identical code runs elementwise across every column at once; you sum or average across columns afterwards to get a portfolio.
3. The lookahead trap, the signature error
Here is the single mistake that produces the most impressive fake backtests. Compute a signal from today's close and multiply it by today's return, with no shift. It looks innocent, and pandas will not complain. But it encodes an impossibility: you are buying at a close you could only know once the bar is over, then collecting the move that already happened during that same bar. The strategy is trading on the answer.
# WRONG: same-bar. The position uses today's signal on today's return.
strat_wrong = signal * returns # lookahead: inflated
# RIGHT: the position is yesterday's signal, applied to today's return.
strat_right = signal.shift(1) * returns # tradable: honest
The wrong line is not a rounding issue. On any signal with real predictive edge it can turn a flat strategy into a soaring one, because on every up day the "position" was set by the very close that made the day up. The tell is a Sharpe that looks too clean and an equity curve with almost no losing stretches. Any time a result looks extraordinary, the first thing to audit is the direction and size of every shift between the signal and the return. And beware the mirror image, shift(-1): pulling a future value into the present row is the same crime committed on purpose.
A related subtlety: even shift(1) assumes you can transact at the same close whose data you used. That is a defensible daily approximation, but if you need to be stricter you shift entries and exits to the next bar's open and price fills there. The principle is unchanged: no line of the return calculation may depend on a bar the position had not been set before.
4. Corporate actions and the phantom crash
A raw close series is blind to what happened to the share itself. When a company does a bonus issue or a stock split, the quoted price steps down mechanically overnight, not because value was lost but because the same value is now spread over more shares. To pct_change that step looks like a savage one-day loss, a phantom crash on a day the holder lost nothing. That single corrupt return then poisons the drawdown, the volatility estimate and the compounded equity for the rest of the run.
The fix is to compute returns from a corporate-action-adjusted series, an adjusted close or a total-return series that already folds splits, bonuses and dividends back into a continuous line, or to apply the adjustment factor to the raw prices before differencing. The mechanics of why the raw and adjusted lines diverge, and how the factor is built, are covered in the backtesting-mistakes walkthrough; the rule for this article is narrow and absolute.
# If you hold an adjustment factor per instrument per day, apply it first.
# `adj_factor` is a same-shaped frame of cumulative split/bonus factors.
adj_prices = prices * adj_factor # continuous, action-aware
returns = adj_prices.pct_change() # now free of phantom crashes
5. A survivorship-bias-free universe
The most flattering error in equity backtesting is not in the arithmetic at all; it is in the choice of what to test on. Take today's index members and run them back over ten years, and you have quietly deleted every company that was delisted, merged away or dropped from the index in that decade. The survivors are, tautologically, the ones that did not fail. A backtest on survivors inherits a tailwind that no live strategy could have caught, because in real time you did not know which names would survive.
This is not a rounding effect. A 2026 study of India's NIFTY Smallcap 250 by Harjot Singh Ranse measured it directly over 2016 to 2025: testing on survivors only, rather than a point-in-time universe, overstated annual returns by 4.94 percentage points, a 23.3 percent relative inflation, and overstated the Sharpe ratio by 0.097. Over the period, 82.5 percent of the index constituents changed. The pull is stronger in small-caps than large-caps because small-caps churn more, but it points the same way in every market: survivors flatter the past.
The mitigation is a point-in-time membership record: for each historical date, the set of instruments that were genuinely in your universe on that date, including the ones later delisted. In pandas you carry a boolean membership mask aligned to the price panel and mask the position by it, so an instrument earns strategy return only on the dates it was actually a member and actually tradable.
# `membership` is a boolean frame, same shape as `prices`, True where an
# instrument was a point-in-time member (delisted names included, up to delist).
eligible = membership.reindex_like(prices).fillna(False)
position = position.where(eligible, 0.0) # no exposure off-universe
# Delisted instruments simply stop contributing once membership goes False.
6. Realistic costs: charge the turnover
A gross backtest assumes you trade for free. You do not. The honest way to bolt costs onto a vectorised framework is to charge for turnover: the amount of position you change on each bar. Turnover is position.diff().abs(), and multiplying it by a per-unit-traded cost rate gives the drag for that bar, which you subtract from the strategy return before compounding. One extra term keeps the whole thing vectorised.
cost_rate = 0.0015 # illustrative round-trip fraction
turnover = position.diff().abs().fillna(0) # position changed this bar
cost = turnover * cost_rate # cost drag per bar
gross = position.shift(1) * returns
net = gross - cost # returns after costs
equity_net = (1.0 + net.fillna(0)).cumprod()
The cost_rate above is a placeholder, not a market figure; set it from the actual round-trip stack you face. The point of putting cost in the loop is the stress test it enables: a rule that turns over its position often can look strong gross and collapse net, and the vectorised form lets you sweep the cost rate upward and watch exactly where the edge disappears. Frequent-trading strategies live or die on this term.
| Cost component | Charged on | Where it enters the model |
|---|---|---|
| Explicit charges (statutory and regulatory levies, exchange fees) | Traded value, per side | Folded into cost_rate as a fraction of turnover |
| Bid-ask spread (half-spread paid on entry and on exit) | Each fill | Add to cost_rate; wider for illiquid names |
| Slippage and market impact | Order size vs available liquidity | Raise cost_rate, or scale it with position size |
| Round-trip count | How often the position flips | Captured automatically by position.diff().abs() |
7. The quieter pitfalls: alignment, NaN, and tuning
Three remaining traps do not announce themselves. The first is alignment. Pandas aligns two objects on their index before any arithmetic, so if your price panel and your signal carry different date indexes, the mismatched dates become NaN and any careless fillna converts them into fake flat days. Reindex everything onto one clean trading-day calendar and never let a forward-fill invent a price on a day the exchange was shut.
The second is NaN discipline. Rolling windows produce leading NaNs, the first return is NaN, and off-universe cells are NaN by design. Decide where each becomes a zero and where it must stay absent. A blanket fillna(0) on positions rather than on returns is a classic way to accidentally hold phantom exposure.
The third is in-sample tuning. If you try many parameter sets and keep the best, its performance is inflated by selection alone: the more you test, the higher the best in-sample result you expect from pure noise. The deflated Sharpe ratio of Bailey and Lopez de Prado makes this precise by adjusting the significance bar for the number of trials and the shape of the return distribution. The practical rule is to hold out data your tuning never touches, and to count how many configurations you tried before trusting any single number. The idea, and its cousins, are developed further in the backtesting-mistakes article and put to work in the pairs setting in the cointegration and pairs-trading walkthrough.
The pipeline, and the catalogue of lies
Two tables to keep beside the code. The first is the pipeline as a checklist: each step, its idiomatic pandas, and the trap it hides. The second is the catalogue of ways a backtest inflates, each with the symptom you would see and the fix.
| Step | Pandas operation | The trap to avoid |
|---|---|---|
| Load and shape | pivot to a date × instrument panel, asfreq | Positional joins from a non-datetime index; hidden missing days |
| Adjust | prices * adj_factor before differencing | The phantom crash from an unadjusted split or bonus |
| Returns | pct_change() on adjusted prices | Running it on raw prices, or on price gaps as if real |
| Signal | A rule such as a moving-average relation | Referencing a future bar, for example any shift(-1) |
| Position | signal.shift(1) | Forgetting the shift, the lookahead trap |
| Universe | position.where(eligible, 0) | A current-members-only roster, survivorship bias |
| Costs | turnover * cost_rate, subtract from return | A cost-free backtest that a frequent trader can never realise |
| Equity | (1 + net).cumprod() | Compounding NaN-contaminated returns |
| Pitfall | Symptom in the results | Fix |
|---|---|---|
| Lookahead (no shift) | Suspiciously smooth equity, few losing runs, Sharpe too clean | Set position = signal.shift(1); ban shift(-1) in the return path |
| Unadjusted corporate action | A one-bar cliff on a day nothing was lost; broken drawdown | Use an adjusted or total-return series, or apply the factor first |
| Survivorship bias | An upward tilt with no obvious cause; live results undershoot | Point-in-time membership; keep delisted names in history |
| Ignored costs | Strong gross, weak or negative net once you add turnover | Charge turnover * cost_rate; sweep the rate upward |
| Alignment or NaN bug | Numbers shift after a reindex; fake zero-return days | One trading-day calendar; deliberate NaN handling, no blind fill |
| In-sample tuning | Great in-sample, poor out-of-sample; one lucky parameter set | Hold-out data; deflate for the number of trials |
Where this sits in a workflow
A vectorised pandas backtest is the right first instrument for daily, position-based equity research: fast, transparent, and easy to reason about line by line. It stops being enough when execution detail dominates, partial fills, intrabar stops, queue position, path-dependent sizing, at which point an event-driven engine that steps bar by bar earns its extra complexity. But most daily research never needs that, provided the four correctness pillars, the shift, the adjustment, the universe and the costs, are all in place. The tooling is the easy part; the choice of instrument and the account setup that precede it are covered in the guide to starting algorithmic trading in India, and the mechanics of pulling live and historical data through a broker API are worked in the broker-API Python tutorial.
Read plainly, the backtest does not create an edge; it tests whether one survives contact with reality. The arithmetic is trivial and the discipline is not: the position that earns a return has to have been decided before that return existed, the prices have to be action-aware, the universe has to include the failures, and the costs have to be charged. That upstream judgement, deciding what is worth testing and refusing to believe a number you cannot reproduce out of sample, is exactly what the method we teach is built around. A backtest is only as honest as the person reading it. For the broader philosophy of validating a strategy before you risk anything on it, see the backtesting overview.
Frequently asked questions
Why do you shift the position by one bar in a pandas backtest?
+Because a signal computed from today's close cannot be traded until the next bar. If you multiply today's return by a position derived from today's close, you are assuming you acted on information you did not yet have. The fix is position = signal.shift(1): the position that earns today's return was decided at yesterday's close. Forgetting the shift silently uses same-bar information and inflates the result, which is the single most common way a pandas backtest lies.
What is the difference between shift(1) and shift(-1) in a backtest?
+shift(1) moves a series forward in time, so the value at each row comes from the previous row. That is what you want for a position: act on yesterday's signal. shift(-1) moves a series backward, pulling a future value into the present row, which is lookahead. Any appearance of shift(-1) in the path from signal to return is a red flag: you are letting the strategy see the bar it is about to trade, and the equity curve becomes fiction.
How do splits and bonuses break a raw-price backtest?
+A raw close series does not know about corporate actions. When a stock does a 1-for-1 bonus, the quoted price roughly halves overnight, so pct_change reports a large negative return on a day nothing was lost. That phantom crash corrupts every downstream number: the drawdown, the volatility and the equity curve. The remedy is to compute returns from an adjusted or total-return series, or to apply the adjustment factor to raw prices before computing returns.
What is survivorship bias in an equity backtest?
+Survivorship bias is testing a strategy only on the instruments that still exist today. If your universe is the current index members applied over past history, you have quietly excluded every company that was delisted, merged or dropped from the index. The survivors are, by definition, the ones that did not fail, so the backtest inherits an upward tilt it could never have captured live. The fix is a point-in-time universe: on each historical date, use the members as they actually were on that date.
How large is survivorship bias for Indian equities?
+A 2026 study of India's NIFTY Smallcap 250 by Harjot Singh Ranse found that testing on survivors only, rather than a point-in-time universe, overstated annual returns by 4.94 percentage points, a 23.3 percent relative inflation, over 2016 to 2025. Constituent turnover was 82.5 percent across the period. The effect is larger in small-caps than large-caps because small-caps churn more, but the direction is the same everywhere: survivors flatter the past.
How do you add transaction costs to a vectorised backtest?
+Model cost as a function of turnover. Turnover on each bar is the absolute change in position, position.diff().abs(). Multiply it by a per-unit-traded cost rate to get the cost drag for that bar, then subtract it from the strategy return before compounding: net = position.shift(1) times asset_return minus turnover times cost_rate. Because the framework stays vectorised, you can raise the cost rate and watch a strategy that looked strong gross turn flat net, which is exactly the stress test frequent traders need.
Why did my backtest change after a reindex or an alignment step?
+Because pandas aligns on the index automatically. If your price panel and your signal have different date indexes, arithmetic between them fills mismatched dates with NaN, and a stray fillna can turn those gaps into fake zero-return days. Reindex both to one clean trading-day calendar, decide deliberately how to treat holidays and missing bars, and never let forward-fill silently invent prices on days the market was shut. Alignment bugs are quiet: the code runs, the numbers are just wrong.
Is a good in-sample Sharpe ratio enough to trust a strategy?
+No. If you tried many parameter sets and kept the best, its Sharpe is inflated by selection: the more combinations you test, the higher the best in-sample Sharpe you expect from noise alone. The deflated Sharpe ratio of Bailey and Lopez de Prado adjusts the significance threshold for the number of trials and the shape of the return distribution. The practical discipline is to hold out data the tuning never saw and to count how many configurations you tried before you believe any single number.
Is a vectorised pandas backtest enough, or do I need an event-driven engine?
+A vectorised backtest is fast and ideal for daily, position-based strategies where one bar's decision maps cleanly to the next bar's return. It becomes awkward when execution detail matters: partial fills, intrabar stops, queue position, path-dependent sizing. Those need an event-driven engine that steps bar by bar. For most daily equity research the vectorised pattern here is the right first tool, provided the shift, the adjustment, the universe and the costs are all correct.
Where the facts come from
- Survivorship bias, India-specific magnitude. Harjot Singh Ranse, "Survivorship Bias in Emerging Market Small-Cap Indices: Evidence from India's NIFTY Smallcap 250" (2026). Survivor-only testing over 2016 to 2025 overstated annual returns by 4.94 percentage points (23.3 percent relative) and the Sharpe ratio by 0.097, with 82.5 percent constituent turnover. arxiv.org/abs/2603.19380
- Selection bias and backtest overfitting. David H. Bailey and Marcos Lopez de Prado, "The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest Overfitting and Non-Normality" (2014): the reported Sharpe of a strategy chosen from many trials must be deflated for the number of trials and the return distribution's shape. papers.ssrn.com
- The shift convention and pandas mechanics. The pandas documentation for
DataFrame.shift,DataFrame.pct_changeand index alignment defines the forward and backward shift, the return computation, and the automatic index alignment on which the lookahead discipline and the alignment pitfalls in this guide rest. - End-of-day Indian equity data. The National Stock Exchange bhavcopy is the daily end-of-day file carrying symbol, series, and open, high, low and close per traded security, filtered to regular equity by the
EQseries, and used here as the source shape for the price panel.
From a backtest to a decision you can defend
Bharath Shiksha is a 30-volume curriculum across 6 stages, from chart reading at ₹14,999 through system design, up to the full bundle at ₹1,49,999. The quant stages build the exact discipline this article foregrounds: honest data, honest assumptions, and results you can reproduce out of sample. Every volume has a companion worksheet, a gate quiz, and a 7-day money-back guarantee.
Take the free diagnostic →