A backtest is the closest thing trading has to a time machine — you run today's rules against yesterday's prices and see what would have happened. The mechanics take an afternoon. Making the result trustworthy is the actual work, and most of this article is about that.
Step 1 — Get the data
from kiteconnect import KiteConnect
import pandas as pd
kite = KiteConnect(api_key="key")
kite.set_access_token("token")
candles = kite.historical_data(
instrument_token=738561, # RELIANCE
from_date="2021-01-01",
to_date="2026-04-01",
interval="day"
)
df = pd.DataFrame(candles)
df.set_index('date', inplace=True)
df.to_csv('reliance_5y.csv')
Save it to disk on the first run. You will iterate on this strategy fifty times, and re-downloading five years of candles each time is slow and wastes your API quota for no reason.
Step 2 — Generate signals
df['ema20'] = df['close'].ewm(span=20).mean()
df['ema50'] = df['close'].ewm(span=50).mean()
df['signal'] = 0
df.loc[df['ema20'] > df['ema50'], 'signal'] = 1 # long
df.loc[df['ema20'] < df['ema50'], 'signal'] = -1 # short
Step 3 — Turn signals into returns
df['returns'] = df['close'].pct_change()
df['strategy_returns'] = df['signal'].shift(1) * df['returns']
df['cum_strategy'] = (1 + df['strategy_returns']).cumprod()
df['cum_buy_hold'] = (1 + df['returns']).cumprod()
That shift(1) is the single most important character in this entire article. Without it you are using today's closing price to decide today's trade — which is not a strategy, it is a look into the future. Backtests that produce absurdly good numbers are usually missing exactly this line.
Step 4 — Measure it
import numpy as np
total_return = df['cum_strategy'].iloc[-1] - 1
print(f"Total return: {total_return*100:.2f}%")
years = len(df) / 252
ann_return = (df['cum_strategy'].iloc[-1] ** (1/years)) - 1
print(f"Annualised: {ann_return*100:.2f}%")
sharpe = np.sqrt(252) * df['strategy_returns'].mean() / df['strategy_returns'].std()
print(f"Sharpe: {sharpe:.2f}")
cum = df['cum_strategy']
running_max = cum.cummax()
drawdown = (cum - running_max) / running_max
print(f"Max drawdown: {drawdown.min()*100:.2f}%")
Of these four numbers, maximum drawdown is the one to look at first. Total return tells you what the strategy earned; drawdown tells you whether you would have still been running it when it did. A curve that returns forty percent after a sixty percent drawdown is a strategy nobody actually holds through.
Step 5 — Plot it
import matplotlib.pyplot as plt
plt.figure(figsize=(12, 6))
plt.plot(df.index, df['cum_strategy'], label='Strategy')
plt.plot(df.index, df['cum_buy_hold'], label='Buy & Hold')
plt.legend()
plt.title('Equity Curve')
plt.show()
Always plot buy-and-hold alongside. A strategy that underperforms simply holding the index is a lot of work for a worse outcome, and the chart makes that obvious in a way the numbers do not.
The five ways a backtest lies
- Look-ahead bias. Future information leaking into the signal. Fixed by
shift(1), and worth checking twice. - Survivorship bias. Testing only on companies that still exist today quietly removes every failure from your sample.
- No costs. Brokerage and slippage take roughly 0.05 to 0.1 percent per trade. On a high-frequency strategy that is the difference between a winner and a loser.
- Over-fitting. Tuning parameters until the past looks perfect. You have described history, not found an edge.
- Single market regime. A trend-following rule tested only across a bull run tells you nothing about how it behaves sideways.
Adding real costs
BROKERAGE = 0.0003 # 0.03%
SLIPPAGE = 0.0005 # 0.05%
df['trade'] = df['signal'].diff().abs()
df['costs'] = df['trade'] * (BROKERAGE + SLIPPAGE)
df['net_returns'] = df['strategy_returns'] - df['costs']
df['cum_net'] = (1 + df['net_returns']).cumprod()
print(f"Net total: {(df['cum_net'].iloc[-1]-1)*100:.2f}%")
Run this and compare the two curves. On a slow strategy the gap is small. On something that trades several times a day, watching a profitable equity curve turn flat is a genuinely useful experience — and better to have it here than with money.
Walk-forward validation
A single backtest across the whole dataset is weak evidence, because you tuned the parameters while looking at all of it.
Split the data by time instead. Tune on the first seventy percent, test on the remaining thirty, then roll the window forward and repeat. If the strategy only works when it can see the test period, walk-forward is where that shows up.
Libraries worth knowing
- backtrader — event-driven and full featured, closest to how live execution actually behaves
- vectorbt — very fast for parameter sweeps
- zipline-reloaded — the maintained descendant of Quantopian's engine
- bt — small and readable, good for portfolio-level tests
Write your own first anyway. Fifty lines of Pandas teaches you where the assumptions hide, and after that you will use a library knowing what it is doing for you.
The short version
A good backtest is an honest one. Include costs, shift your signals, test across different market conditions, and be suspicious of any result that looks wonderful. Historical performance is not a prediction — it is a sanity check on whether the idea is worth risking money on at all.
We backtest custom strategies for clients and deliver the full report, including the runs where the strategy did not work. Those are usually the more useful ones.
FAQs
Why do live results differ from the backtest?
Usually slippage and brokerage that were left out, parameters over-fitted to the past, or a change in market conditions. Add real costs first — that alone explains most of the gap. Past performance is not an indicator of future behaviour.
How many years of data do I need?
Three to five as a minimum, so the test spans more than one type of market. A strategy tested only across a strong trend tells you very little about how it handles a sideways year.
What is look-ahead bias in simple terms?
Using information in a decision that would not have existed at that moment — for example, using today's closing price to decide today's trade. Shifting the signal by one period fixes the common case.
Should I write my own backtester or use a library?
Write a simple one first. Fifty lines of Pandas shows you exactly where the assumptions live. Once you understand that, a library like backtrader or vectorbt saves time without hiding anything from you.
Need a custom solution?
Instacode builds production-grade software — algo trading, ecommerce, web apps. Let's talk.
Get in Touch
💬 Comments (0)
Be the first to comment 🚀