How to Backtest an Options Strategy with Historical NIFTY Data in Python
Most options backtests fail for reasons that have nothing to do with the strategy. They fail on data handling — on survivorship, on fills, on lookahead — and produce equity curves that are artefacts of the harness rather than results.
This walks through a NIFTY options backtest in Python, with the failure modes called out where they actually occur.
What makes options backtesting different
If you have backtested equities, the instinct is to fetch a price series and iterate. Options break that instinct in three ways.
The universe changes constantly. An equity ticker persists. A NIFTY option contract exists for weeks, then stops existing. Any given expiry has hundreds of strikes, most of which never trade meaningfully. Your universe on any date is a function of that date.
You cannot reconstruct the price. Given a spot series you can compute a stock’s returns. Given a NIFTY spot series you cannot recover what the 25,000 CE actually traded at, because that depends on implied volatility, which is precisely the thing you do not have. You need the recorded premium.
Liquidity is wildly uneven. The at-the-money weekly strike is deeply liquid. The same expiry’s far out-of-the-money strikes may go minutes without a trade. A backtest that assumes every strike is fillable at its last price will produce returns that were never available.
Getting the data
The shape you want is: for a given expiry, the per-interval OHLCV and OI series for each strike you might trade.
Start by listing the expiries that actually have data:
import requests
BASE = "https://api.moneyticks.com/api/v1/options"
HEADERS = {"Authorization": "Bearer mt_live_your_api_key"}
r = requests.get(f"{BASE}/NIFTY/expiries", headers=HEADERS)
r.raise_for_status()
expiries = [e["date"] for e in r.json()["expiries"]]
print(len(expiries), "expiries;", expiries[-3:])
Then pull a contract’s candles. The endpoint is keyset-paginated, so wrap it in a loop that follows nextCursor rather than assuming one response holds everything:
import pandas as pd
def fetch_candles(symbol, expiry, strike, option_type, interval="1m"):
"""Pull a full candle series, following pagination to the end."""
rows, cursor = [], None
while True:
params = {
"expiry": expiry,
"strike": strike,
"optionType": option_type,
"interval": interval,
}
if cursor:
params["cursor"] = cursor
r = requests.get(f"{BASE}/{symbol}/candles", params=params, headers=HEADERS)
r.raise_for_status()
payload = r.json()
rows.extend(payload["candles"])
cursor = payload.get("nextCursor")
if not cursor:
break
df = pd.DataFrame(rows)
if df.empty:
return df
df["ts"] = pd.to_datetime(df["ts"])
return df.set_index("ts").sort_index()
Rate limits apply — 60 requests per minute overall, with candles capped at 30 per minute and chain at 20. A multi-year backtest pulls a lot of contracts, so cache aggressively to local parquet. Expired contract data never changes, which makes it perfectly cacheable:
from pathlib import Path
CACHE = Path("cache")
CACHE.mkdir(exist_ok=True)
def cached_candles(symbol, expiry, strike, option_type, interval="1m"):
key = CACHE / f"{symbol}_{expiry}_{strike}{option_type}_{interval}.parquet"
if key.exists():
return pd.read_parquet(key)
df = fetch_candles(symbol, expiry, strike, option_type, interval)
if not df.empty:
df.to_parquet(key)
return df
The three traps
Survivorship bias, options edition
The equity version of survivorship bias is testing on today’s index constituents. The options version is subtler: you pick strikes with hindsight.
It happens quietly. You decide to test a short-straddle strategy, you look at an expiry, you pick the strike that was at-the-money — but “at-the-money” measured when? If you used the spot at any point after your entry decision, you have leaked the future into strike selection.
The fix is to select strikes from information available at decision time only. A clean way to do this without needing a separate spot feed is put-call parity: the at-the-money strike is the one where the call and put premiums are closest together.
def atm_strike(chain):
"""ATM strike inferred from the chain itself, via put-call parity.
Uses only prices recorded in the snapshot, so there is no way for a later
spot value to leak into the choice.
"""
candidates = [
(abs(row["ce"]["close"] - row["pe"]["close"]), row["strike"])
for row in chain["chain"]
if row.get("ce") and row.get("pe")
]
if not candidates:
return None
return min(candidates)[1]
The at parameter on the chain endpoint pins the snapshot to a timestamp, which is what keeps the selection honest — ask for the chain as it stood at your decision time, not as it ended the day:
def fetch_chain_at(symbol, expiry, at):
r = requests.get(
f"{BASE}/{symbol}/chain",
params={"expiry": expiry, "at": at},
headers=HEADERS,
)
r.raise_for_status()
return r.json()
Fill assumptions
Backtests fill at the close of the signal candle. Reality does not.
Options spreads are wide, and they widen exactly when you most want to trade. A strike showing a last price of ₹42 may have a book of ₹40 bid / ₹44 offer — a 10% round-trip cost before any strategy edge. On far out-of-the-money strikes it is worse.
At minimum, apply a spread haircut and refuse to trade contracts that were not actually liquid:
SPREAD_HAIRCUT = 0.01 # 1% of premium, each way — tune per liquidity band
MIN_VOLUME = 100 # contracts in the decision interval
def executable(candle):
return candle["volume"] >= MIN_VOLUME
def fill_price(candle, side):
px = candle["close"]
return px * (1 + SPREAD_HAIRCUT) if side == "buy" else px * (1 - SPREAD_HAIRCUT)
The volume filter matters more than the haircut. A strategy whose returns come from strikes that traded twice all day is not a strategy.
Lookahead in the candle
The most common bug: using a candle’s close to decide something that would have been decided during that candle.
If your signal is “enter when the 15-minute close crosses above X”, the earliest you can act is the next candle’s open. Using the same candle’s close as the entry price means you traded on information that did not exist yet.
signal = df["close"] > df["close"].rolling(20).mean()
entry = signal.shift(1) # act on the next bar
price = df["open"] # at that bar's open, not the signal bar's close
The related trap is stops. A 15-minute candle with a low of ₹18 tells you the price touched ₹18 at some point — not when. If your stop sits at ₹20, you cannot tell from the 15-minute bar whether it triggered before or after your target. Re-check stops on 1-minute data.
Measuring the result
Once the harness is honest, be equally honest about the output.
Report costs explicitly. Brokerage, STT, exchange charges, stamp duty and GST add up fast on options, and STT on exercised in-the-money options at expiry is large enough to flip a strategy’s sign on its own.
Count trades. Forty trades is not a sample. A Sharpe ratio computed over a handful of trades is noise with a decimal point.
Split by regime. Run the strategy separately across calm and volatile periods. A short-volatility strategy will look superb until it does not, and an aggregate number hides exactly the periods that matter. This is the practical argument for a long sample — MoneyTicks holds NIFTY and BANK NIFTY back to August 2021, which covers several distinct regimes.
Keep an untouched holdout. Decide the holdout period before you start, and do not look at it until the strategy is final. Every parameter you tune on the full sample is a small overfit, and they compound.
Putting it together
The skeleton, with the guards in place:
def backtest(symbol, expiry, decision_ts, entry_time, exit_time):
# Chain as it stood at the decision timestamp — never the day's close.
chain = fetch_chain_at(symbol, expiry, decision_ts)
strike = atm_strike(chain)
if strike is None:
return None
ce = cached_candles(symbol, expiry, strike, "CE", "15m")
pe = cached_candles(symbol, expiry, strike, "PE", "15m")
if ce.empty or pe.empty:
return None
entry_bar = ce.index[ce.index.time >= entry_time][0]
exit_bar = ce.index[ce.index.time >= exit_time][0]
for leg in (ce, pe):
if not executable(leg.loc[entry_bar]):
return None # skip illiquid strikes entirely
# Short straddle: sell both legs at entry, buy back at exit.
credit = (
fill_price(ce.loc[entry_bar], "sell")
+ fill_price(pe.loc[entry_bar], "sell")
)
debit = (
fill_price(ce.loc[exit_bar], "buy")
+ fill_price(pe.loc[exit_bar], "buy")
)
return {"expiry": expiry, "strike": strike, "pnl": credit - debit}
Run it across every expiry, aggregate, subtract costs, and look at the distribution rather than the total. Then look at the holdout.
The data underneath
None of this works without per-contract history that survives expiry. Live option chains show you what is trading now; a backtest needs what traded then, including for contracts that no longer exist.
MoneyTicks keeps 1-minute OHLCV and open interest for expired NIFTY and BANK NIFTY contracts from August 2021 and SENSEX from March 2024 — around 250 million recorded candles. You can browse any expiry’s chain and any strike’s full history free from the options data catalogue, pull it through the API at 1m, 5m, 15m, 1h or 1d, or export to CSV and Excel from the dashboard. Access is a one-time ₹999 payment plus 18% GST — the complete archive plus one month of continuing data from purchase. Not a recurring subscription.
If you are just starting, read how to read NIFTY open interest first — OI filters are one of the more productive additions to an options backtest, and one of the easiest to get subtly wrong.
Frequently asked questions
What data do I need to backtest an options strategy?
At minimum, per-contract price history for every strike and expiry you might trade, with timestamps at or below your decision frequency. For intraday strategies that means 1-minute candles. Index spot alone is not enough, because you cannot recover an option's traded premium from the index level without assuming a pricing model and an implied volatility you do not have.
Can I backtest options strategies with free data?
Partly. Free sources typically give end-of-day option chains or a recent rolling window, which is enough for coarse positional studies. Intraday strategies and multi-year samples generally need a paid source, because storing per-minute data for every strike across years is expensive to maintain.
Why do my backtest results look too good?
The three usual causes in options backtests are survivorship bias from only testing strikes that ended up interesting, fill assumptions that ignore the bid-ask spread, and lookahead from using a candle's close to make a decision that would have been taken during that candle. All three inflate results and all three are avoidable.
How far back should an options backtest go?
Far enough to cover more than one volatility regime. A strategy tested only on 2023-2024 has never seen a real volatility shock. MoneyTicks holds NIFTY and BANK NIFTY history from August 2021, which spans several distinct regimes.
What interval should I use for backtesting?
Match the interval to your decision frequency, then verify at a finer one. If you decide every 15 minutes, backtest on 15-minute candles but re-check fills on 1-minute data, since a 15-minute candle can hide a spike that would have triggered a stop.