Skip to main content

Backtesting a Trading Strategy in Python, Honestly

FB THEFINBABA

Backtesting is the cheapest way to find out that an idea does not work. It is also the easiest place in all of trading to lie to yourself, because every mistake pushes the equity curve in the flattering direction.

This walkthrough builds a small backtest in pandas against Indian market data, then spends most of its length on the part that matters: subtracting costs, removing the biases that inflate results, and testing on data you did not use while designing the strategy.

What a backtest can and cannot tell you

A backtest can tell you three useful things: whether your rules are even coherent, roughly how often they trade, and how bad the worst historical stretch was. That last one is the real product.

It cannot tell you what the strategy will earn next year. Markets change regime, liquidity moves, and the specific conditions that made 2020 profitable are not coming back in the same shape. Treat a backtest as a filter that rejects bad ideas, not as a forecast.

If the backtest is bad, the strategy is almost certainly bad. If the backtest is good, the strategy might be good. That asymmetry is the whole reason to run one.

Getting historical data in India

You need clean, adjusted historical data. In India, the practical sources are your broker's API, a paid data vendor, or the exchange's own bhavcopy files. Kite Connect gives you daily and intraday candles with a historical data subscription, which is the simplest starting point if you already have the API.

import pandas as pd
from kiteconnect import KiteConnect

kite = KiteConnect(api_key='your_api_key')
kite.set_access_token('your_access_token')

candles = kite.historical_data(
    instrument_token=256265,       # NIFTY 50 index
    from_date='2019-01-01',
    to_date='2025-12-31',
    interval='day'
)

df = pd.DataFrame(candles)
df['date'] = pd.to_datetime(df['date'])
df = df.set_index('date').sort_index()

print(df.tail())

Two cautions specific to Indian equity data. Prices must be adjusted for splits and bonuses, or a 1:5 split will read as an 80% crash in your backtest. And if you are testing stocks rather than an index, the list you test on must be the list as it existed then — more on that below.

A minimal backtest in pandas

Here is a complete moving-average crossover backtest. It is deliberately plain, because the interesting part is what comes after it.

FAST, SLOW = 20, 50

df['fast'] = df['close'].rolling(FAST).mean()
df['slow'] = df['close'].rolling(SLOW).mean()

# Signal is decided using today's close...
df['signal'] = (df['fast'] > df['slow']).astype(int)

# ...and acted on tomorrow. This one shift is the
# difference between a backtest and a fantasy.
df['position'] = df['signal'].shift(1).fillna(0)

df['ret']   = df['close'].pct_change()
df['strat'] = df['position'] * df['ret']

That .shift(1) deserves the comment it gets. Without it you are buying at a price you could only have known after the decision was made, and every strategy looks brilliant. It is the most common bug in beginner backtests and it is invisible unless you look for it.

Adding costs, which is where most edges die

A backtest without costs is a description of a market that does not exist. For Indian equity you are paying brokerage, STT, exchange transaction charges, GST, SEBI turnover fees and stamp duty — and on top of all that, slippage, which is usually larger than the rest combined for anything less liquid than the index.

Model it as a single round figure per side and be pessimistic. Six basis points per side is a reasonable starting assumption for liquid intraday equity; for options it will be higher.

COST_PER_SIDE = 0.0006   # 6 bps: charges + slippage

# position changes = a trade happened
df['trades'] = df['position'].diff().abs().fillna(0)
df['strat_net'] = df['strat'] - df['trades'] * COST_PER_SIDE

equity_gross = (1 + df['strat']).cumprod()
equity_net   = (1 + df['strat_net']).cumprod()

print('gross', round(equity_gross.iloc[-1], 2))
print('net  ', round(equity_net.iloc[-1], 2))

Run those two lines against a strategy that trades several times a week and the gap is often the entire result. This single comparison kills more bad ideas than any other test.

The metrics that matter more than total return

Total return is the number people quote and the least useful one. These four decide whether a strategy is livable.

years = len(df) / 252
cagr  = equity_net.iloc[-1] ** (1 / years) - 1

peak     = equity_net.cummax()
drawdown = equity_net / peak - 1
max_dd   = drawdown.min()

sharpe = (df['strat_net'].mean() / df['strat_net'].std()) * (252 ** 0.5)

print(f'CAGR       {cagr:.2%}')
print(f'Max DD     {max_dd:.2%}')
print(f'Sharpe     {sharpe:.2f}')
print(f'Trades     {int(df["trades"].sum())}')

Maximum drawdown is the worst peak-to-trough fall. If it reads 40%, ask honestly whether you would keep running the system four months into that fall. Most people would not, which makes the CAGR above it theoretical.

Trade count matters because statistics need samples. Thirty trades over six years tells you almost nothing; the result is noise wearing a suit.

Five ways a backtest lies to you

Look-ahead bias. Using information that was not available at decision time — the missing .shift(1), or a daily high used to decide an entry at the open.

Survivorship bias. Backtesting today's Nifty 50 constituents over ten years. The companies that were dropped are missing, so you have tested a list selected for having survived. Use the historical constituent list.

Overfitting. Trying 400 parameter combinations and keeping the best. You have not found a strategy, you have found the combination that best fits the noise in that particular sample.

Ignoring liquidity. Assuming you get filled at the close price on a stock trading 8,000 shares a day. Check that your position size is a small fraction of typical volume.

Restarting the clock. Quietly changing the start date because 2018 looked bad. If the strategy only works from 2020 onward, the honest description is that it has worked for a few years, which is a much weaker claim.

Walk-forward: the test that actually predicts something

A single backtest over the whole history tells you how well your parameters fit that history. Walk-forward testing asks the question you actually care about: if I had chosen the parameters using only past data, how would they have done next?

splits = [
    ('2019-01-01', '2021-12-31', '2022-01-01', '2022-12-31'),
    ('2020-01-01', '2022-12-31', '2023-01-01', '2023-12-31'),
    ('2021-01-01', '2023-12-31', '2024-01-01', '2024-12-31'),
]

for tr_from, tr_to, te_from, te_to in splits:
    params = optimise(df[tr_from:tr_to])       # choose FAST/SLOW here
    result = evaluate(df[te_from:te_to], params)  # and only score here
    print(te_from[:4], params, round(result, 3))

If the parameters chosen on each training window keep changing wildly, the strategy has no stable edge — you are fitting noise. If they stay in a similar range and the out-of-sample results stay positive, you have something worth paper trading.

This is the step that separates a strategy from a curve-fitted chart, and it is the one almost nobody does.

From a passing backtest to a live system

A backtest that survives costs, bias checks and walk-forward has earned exactly one thing: the right to be paper traded. Run it on live data with simulated fills for a month and compare the two sets of results. Divergence is almost always slippage or an assumption in your fill logic.

Only then does it go live, at the smallest size the instrument permits. And to run at all it needs a broker account with API access, since that is what supplies both your historical data and your orders.

Historical data and live orders

Backtesting needs broker API access

Kite Connect supplies the candles used in this article. Account free to open·API billed separately

If you would rather learn this as a structured sequence than assemble it from articles, backtesting is one section of our Algorithmic Trading with Python course at Rs 24,900 — alongside the API integration, the virtual trading system and 24x7 deployment. One payment, lifetime access, free demo on WhatsApp first.

We sell no tips or calls, manage no money, and promise no returns. Trading carries a real risk of loss.

Disclosure: the account-opening link on this page is under Atul Shrivastava's Zerodha Authorised Person registration (NSE AP Reg: AP2516003481; Zerodha Broking Ltd. SEBI Reg: INZ000031633) and earns a revenue share. TheFinBaba is not a SEBI-registered Investment Adviser — this content is educational, not investment advice.

Frequently Asked Questions

Which Python library should I use for backtesting?

For a first strategy, plain pandas is enough and teaches you more, because nothing is hidden. Once your logic gets complex, frameworks like Backtrader, vectorbt or backtesting.py take care of order handling and position accounting for you.

How many years of data do I need to backtest a strategy?

Enough to include more than one market regime - five to seven years for a daily strategy is a reasonable minimum. Trade count matters more than calendar length: a few hundred trades gives a far more meaningful result than thirty.

What costs should I assume in an Indian equity backtest?

Brokerage, STT, exchange transaction charges, GST, SEBI fees, stamp duty and slippage. Rather than modelling each, use a single pessimistic figure per side - around six basis points is a sensible starting point for liquid intraday equity, higher for options and illiquid stocks.

Why does my strategy work in backtest but lose money live?

The three usual causes are look-ahead bias in the code, costs and slippage left out or underestimated, and overfitting from tuning parameters on the same data used to evaluate them. Walk-forward testing exposes the third.

Can I backtest options strategies in Python?

Yes, but it is considerably harder. You need historical option chain data with the correct strikes and expiries, and you must model the wider bid-ask spreads properly. Start with equity or index strategies before attempting options.

Disclaimer: TheFinBaba provides educational content only - this is not investment advice. Trading involves risk of loss.

Atul Shrivastava
Written by

Atul Shrivastava

Founder & Lead Trainer, TheFinBaba

16+ years in the markets. 8+ years teaching Python algo trading.

Full profile

Found this useful? Share it:

WhatsApp Share

Disclaimer: TheFinBaba provides educational content only. Nothing in this article is investment advice or a recommendation to buy or sell any security. Trading in financial markets carries risk of loss — make every decision based on your own research and risk capacity.

Free Account Opening

Open a Free Demat & Trading Account in 5 Minutes

Start trading stocks, F&O, IPOs, bonds & ETFs with Zerodha — India's most trusted discount broker. Zero account-opening fee, paperless Aadhaar KYC, and the best APIs (Kite Connect) for Python algo trading.

  • Zero account-opening fee
  • ₹0 brokerage on equity delivery
  • Kite Connect API — best for Python algo traders
  • Paperless Aadhaar e-KYC in 5 minutes
Open Free Demat Account

* Zerodha is SEBI-registered. Account opening subject to KYC approval. Atul Shrivastava is an Authorised Person (AP) — Reg AP2516003481.

Trusted by 1.6 Cr+ Indian Investors

Zerodha is India's largest stock broker by active clients (NSE data, 2026).

₹0
Delivery
₹20
Intraday
5 min
Opening