Every backtest starts with data, and this is the step that quietly wastes the most time. Not because the endpoint is hard, but because of the parts nobody mentions: how far back each interval goes, why a five-year request returns an error, and why a stock that split two years ago will make your strategy look brilliant.
This is the practical version — getting clean, usable candles into a DataFrame and keeping them.
What you need before the first request
Three things, and the second one costs extra.
- A Zerodha account with Kite Connect enabled
- The historical data add-on, which is billed separately from the API subscription itself — this catches people out
- A valid access token for the session, which expires daily
You also need the instrument token, which is a number rather than a symbol. Kite addresses everything internally by token, so RELIANCE means nothing to the endpoint until you look it up.
import pandas as pd
from kiteconnect import KiteConnect
kite = KiteConnect(api_key='your_api_key')
kite.set_access_token('your_access_token')
inst = pd.DataFrame(kite.instruments('NSE'))
token = int(inst.loc[inst['tradingsymbol'] == 'RELIANCE',
'instrument_token'].iloc[0])
print(token)That instrument list is large and changes rarely. Download it once each morning and cache it — fetching it before every request is slow and pointless.
The first request, and the shape of what comes back
candles = kite.historical_data(
instrument_token=token,
from_date='2025-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.head())
# columns: open, high, low, close, volumeTwo details worth knowing immediately. The timestamps come back timezone-aware in IST, so comparing them against a naive datetime you built yourself will raise an error — convert one or the other rather than stripping the timezone and hoping.
And for derivatives, pass oi=True to get open interest as an extra column. It is not included by default, and its absence is a common reason an options backtest silently ignores the most useful field available to it.
Interval limits: why your five-year request fails
This is the single most common wall people hit. Each interval has a maximum number of days you may request in one call, and the finer the interval, the shorter the window.
Daily candles allow a long range in one request. Hourly allows less. One-minute allows only a handful of days at a time. Ask for two years of minute data in one request and you get an error rather than a truncated result, which is at least honest of it.
The limits change occasionally, so read them from the Kite Connect documentation rather than trusting a number in any article. What does not change is that you have to chunk.
from datetime import timedelta
def fetch_range(kite, token, start, end, interval, chunk_days):
out = []
cur = start
while cur < end:
stop = min(cur + timedelta(days=chunk_days), end)
out += kite.historical_data(token, cur, stop, interval)
cur = stop + timedelta(days=1)
df = pd.DataFrame(out)
df['date'] = pd.to_datetime(df['date'])
return df.set_index('date').sort_index()Add a small pause between chunks. Requesting fifty windows back to back will hit the rate limit, and being throttled halfway through a download leaves you with a partial dataset that looks complete.
Cache it locally, and stop re-downloading
Historical data does not change. Downloading the same three years every time you tweak a strategy is slow, burns rate limit, and makes you reluctant to test as often as you should.
Write it to disk once, in a columnar format, and read from there.
from pathlib import Path
CACHE = Path('data')
CACHE.mkdir(exist_ok=True)
def get_candles(kite, symbol, token, start, end, interval='day'):
f = CACHE / f'{symbol}_{interval}.parquet'
if f.exists():
df = pd.read_parquet(f)
if df.index.min() <= start and df.index.max() >= end:
return df.loc[start:end]
df = fetch_range(kite, token, start, end, interval, chunk_days=60)
df.to_parquet(f)
return dfParquet rather than CSV: it is far smaller, loads much faster, and preserves types so your dates do not come back as strings. For minute data across a watchlist the difference is not marginal.
The split and bonus problem
Here is the one that silently ruins backtests.
If a stock did a 1:5 split, the price series shows a fall from around 2,500 to around 500 on that date. Your strategy sees an 80% single-day crash. A momentum rule will read it as a catastrophic move; a mean-reversion rule will read it as the buying opportunity of the decade and appear to make an enormous, entirely fictional profit.
The same applies to bonus issues. Volume is affected too, in the opposite direction.
Three ways to handle it, in order of practicality:
- Test indices and index derivatives. They are not subject to corporate actions, which is one reason most retail systematic strategies start there.
- Use adjusted data from a vendor that applies corporate actions for you, if the strategy needs individual stocks.
- Adjust it yourself from a corporate actions list — divide all prices before the ex-date by the ratio and multiply volumes by it. Workable, and you must maintain the list.
Whichever route, add a sanity check to your loader: flag any single-day move beyond a threshold and look at it before trusting the series.
ret = df['close'].pct_change()
suspicious = ret[abs(ret) > 0.35]
if len(suspicious):
print('Check these dates for corporate actions:')
print(suspicious)
Other gaps worth knowing about
Expired contracts. Historical data for derivatives is addressed by the instrument token of that specific contract, and tokens for expired contracts are not in the current instrument list. Testing an options strategy across past expiries means keeping your own record of tokens as they existed, which is why people who did not plan for it end up unable to backtest options at all.
Holidays are absent, not zero. The series simply skips non-trading days. Any calculation assuming consecutive calendar days will drift; work on the index you were given rather than generating your own date range.
Pre-open and auction data. The first candle of the day may behave differently from what you expect. Check what a 9:15 candle actually contains before building a rule that depends on the open.
How far back it goes varies by instrument and interval. Assume less than you hope, and check for your specific instrument before designing a ten-year test.
A loader worth reusing
Pulling it together, the thing worth writing once and importing everywhere:
def load(kite, symbol, exchange, start, end, interval='day'):
inst = instrument_master(kite, exchange) # cached daily
token = int(inst.loc[inst['tradingsymbol'] == symbol,
'instrument_token'].iloc[0])
df = get_candles(kite, symbol, token, start, end, interval)
# sanity checks - chup-chaap galat data se behtar hai ki shor kare
assert df.index.is_monotonic_increasing, 'dates out of order'
assert not df.index.duplicated().any(), 'duplicate candles'
assert (df['high'] >= df['low']).all(), 'high below low'
return dfThose three assertions look excessive until the first time one of them fires. Silently wrong data produces a backtest that is confidently wrong, which is considerably worse than a crash.
All of this needs a Zerodha account with Kite Connect and the historical data add-on enabled.
Historical data comes from your broker
The account is free; Kite Connect and the data add-on are billed separately. Aadhaar OTP
The loader, the caching layer and the corporate-action checks are all part of our Algorithmic Trading with Python course at Rs 24,900 — one payment, permanent access, free demo on WhatsApp first.
We sell no tips and no signal group, we manage nobody's money, and we 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
Is Zerodha historical data free?
No. It is an add-on billed separately from the Kite Connect API subscription itself, which is a common surprise when people budget for automation.
Why does my historical data request return an error?
Usually the range exceeds the per-request limit for that interval. Finer intervals allow shorter windows - minute data only a few days at a time. Split the range into chunks and pause briefly between them to avoid the rate limit.
How far back does Zerodha historical data go?
It varies by instrument and interval, and daily candles reach much further back than minute candles. Check for your specific instrument before designing a long test rather than assuming a number.
Does the Zerodha API adjust prices for splits and bonuses?
Do not assume it does. An unadjusted 1:5 split reads as an 80% single-day crash and will make a mean-reversion strategy look spectacularly profitable. Test indices where corporate actions do not apply, use adjusted vendor data, or adjust it yourself - and add a check that flags any implausible single-day move.
Can I backtest options with Zerodha historical data?
Only if you have the instrument tokens for those contracts. Expired contracts drop out of the current instrument list, so unless you stored tokens as they existed, past expiries become inaccessible. Start capturing the instrument master daily if options backtesting is your plan.
How should I store downloaded candles?
Locally, in Parquet rather than CSV, partitioned by symbol and interval. It is smaller, loads far faster, and preserves types. Historical data never changes, so re-downloading it is wasted time and wasted rate limit.
Related Reading
- Kite Connect API in Python - step-by-step tutorial
- Backtesting a trading strategy in Python
- Zerodha API charges, broken down
- Live tick data with the Kite Connect WebSocket
- Setting up paper trading in India
- Iron condor strategy in India
- Algorithmic Trading with Python - full syllabus
Disclaimer: TheFinBaba provides educational content only - this is not investment advice. Trading involves risk of loss.