Guides /
Rebuilding a historical NIFTY option chain, with implied volatility, from 1-minute data
Turn 1-minute option bars into the option chain as it stood at any past minute: strikes, prices, volume, open interest, put-call ratio, and implied volatility computed with Black-Scholes.
A live option chain is a snapshot: every strike of one expiry, calls on one side and puts on the other. With 1-minute history you can rebuild that snapshot for any minute in the past, and add the fields a trading screen computes for you, such as implied volatility.
The snapshot
import pandas as pd
opt = pd.read_parquet("NIFTY_OPT_1min_2025-10-14_exp2025-10-20.parquet")
opt["cp"] = opt["right"].str[0].str.upper()
t = pd.Timestamp("2025-10-14 11:00")
# last known bar for each contract at or before t
snap = (opt[opt["datetime"] <= t].sort_values("datetime")
.groupby(["strike_price", "cp"]).last())
chain = snap[["close", "volume", "open_interest"]].unstack("cp")
chain.columns = [f"{c}_{side}" for c, side in chain.columns]
print(chain.loc[24800:25600])
Using the last bar at or before the snapshot time matters. Illiquid strikes may not trade every minute, and taking only rows stamped exactly 11:00 would leave holes.
Put-call ratio
pcr_oi = snap.xs("P", level="cp")["open_interest"].sum() / snap.xs("C", level="cp")["open_interest"].sum()
print(round(pcr_oi, 2))
The files report open interest as it stood at that minute. Note that the volume in this snapshot is the volume of that one bar, not the cumulative day volume a live screen shows; sum the bars up to t if you want the running total.
Implied volatility
The files hold traded prices, so implied volatility is something you compute. With the index level, strike, time to expiry and an interest rate, invert Black-Scholes for each option:
import numpy as np
from scipy.optimize import brentq
from scipy.stats import norm
def bs_price(S, K, T, r, sigma, cp):
d1 = (np.log(S / K) + (r + sigma ** 2 / 2) * T) / (sigma * np.sqrt(T))
d2 = d1 - sigma * np.sqrt(T)
if cp == "C":
return S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
return K * np.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1)
def implied_vol(price, S, K, T, r, cp):
intrinsic = max(0.0, S - K) if cp == "C" else max(0.0, K - S)
if price <= intrinsic + 0.05 or T <= 0:
return np.nan
return brentq(lambda s: bs_price(S, K, T, r, s, cp) - price, 1e-4, 5.0)
spot = pd.read_parquet("NIFTY_SPOT_1min_2025-10-14.parquet").set_index("timestamp")["close"]
S = spot.asof(t)
expiry = pd.Timestamp("2025-10-20 15:30")
T = (expiry - t).total_seconds() / (365 * 24 * 3600)
r = 0.06
for k in range(24900, 25401, 100):
for cp in "CP":
px = snap.loc[(float(k), cp), "close"]
print(k, cp, px, round(implied_vol(px, S, k, T, r, cp) * 100, 1))
Refinements
- Use the futures price. Index options price off the forward. Replacing S with the matching futures close (and r with 0) removes most of the put-call skew that comes from carry.
- Time to expiry. Calendar time is a reasonable start. Trading-time clocks give smoother intraday IV on expiry day.
- Greeks. Once you have sigma, delta, gamma, vega and theta follow from the same d1 and d2.