Guides /
Backtesting a NIFTY options strategy in pandas with 1-minute data
A complete, small backtest of an intraday short straddle on NIFTY 1-minute option data: loading files, choosing the strike, entry, exit, costs and a results table.
This guide builds a working intraday backtest from raw option files. The strategy is deliberately simple, a short at-the-money straddle opened in the morning and closed in the afternoon, because the point is the plumbing: getting the right strike at the right minute, without looking ahead, and accounting for costs.
The rules
- At 09:20, read the NIFTY index close and round it to the nearest 50 to get the at-the-money strike.
- Sell one call and one put at that strike in the nearest expiry, at the 09:20 close.
- Buy both back at the 15:15 close. No stop-loss in this first version.
Loading one day
Each pack has one option file per day per expiry, plus a daily index file. The nearest expiry for a day is simply the smallest expiry date among that day's option files.
import re
from pathlib import Path
import pandas as pd
ROOT = Path("data")
OPT = re.compile(r"NIFTY_OPT_1min_(\d{4}-\d{2}-\d{2})_exp(\d{4}-\d{2}-\d{2})")
def day_files(day):
hits = [(m[2], f) for f in ROOT.rglob(f"NIFTY_OPT_1min_{day}_exp*.parquet")
if (m := OPT.search(f.name))]
return sorted(hits)[0][1] # nearest expiry
def load_options(f):
d = pd.read_parquet(f).rename(columns={
"datetime": "ts", "timestamp": "ts", "strike_price": "strike",
"right": "cp", "option_type": "cp"})
d["cp"] = d["cp"].astype(str).str[0].str.upper() # C or P
return d.set_index("ts")
def load_index(day):
f = next(ROOT.rglob(f"NIFTY_SPOT_1min_{day}.parquet"))
d = pd.read_parquet(f)
ts = "timestamp" if "timestamp" in d else "datetime"
return d.set_index(ts)["close"]
One trade
def trade(day, t_in="09:20", t_out="15:15", cost_per_leg=1.0):
spot = load_index(day)
opt = load_options(day_files(day))
t0, t1 = pd.Timestamp(f"{day} {t_in}"), pd.Timestamp(f"{day} {t_out}")
k = round(spot.loc[t0] / 50) * 50
leg = lambda cp, t: opt[(opt.strike == k) & (opt.cp == cp)]["close"].asof(t)
sold = leg("C", t0) + leg("P", t0)
bought = leg("C", t1) + leg("P", t1)
return {"day": day, "strike": k, "sold": sold, "bought": bought,
"pnl_pts": sold - bought - 4 * cost_per_leg}
asof takes the last close at or before the requested minute, so a strike with no trade in that exact minute still gets the latest known price, never a future one. cost_per_leg is a flat allowance in index points for fees, taxes and slippage on each of the four fills; set it from your own cost sheet.
Many days
days = sorted({OPT.search(f.name)[1] for f in ROOT.rglob("NIFTY_OPT_1min_*.parquet")})
res = pd.DataFrame([trade(d) for d in days]).set_index("day")
print(res["pnl_pts"].describe())
print("win rate", (res["pnl_pts"] > 0).mean())
print("worst day", res["pnl_pts"].idxmin(), res["pnl_pts"].min())
What to add next
- Stops. Walk the minutes between entry and exit and close when the combined premium rises past a limit. Check each minute's
high, not only the close, or you will understate losses. - Position size in lots. P&L above is in index points per unit. Multiply by the lot size in force on each date.
- Regimes. Split results by days to expiry. A short straddle on expiry day behaves very differently from the same trade a week out.