Implied volatility (Newton-Raphson solve)
Black-Scholes (chapter 118) takes volatility as an *input* to produce a price. Implied volatility (IV) inverts this: given the market's actual quoted price, what volatility would Black-Scholes need as input to reproduce it? There's no closed-form inverse — you solve it numerically.
def implied_volatility(market_price: float, S: float, K: float, T: float, r: float, option_type: str = "CE",
initial_guess: float = 0.20, max_iterations: int = 100, tolerance: float = 1e-5) -> float | None:
"""Newton-Raphson: iteratively refine sigma until the model price matches the market price."""
sigma = initial_guess
for _ in range(max_iterations):
price = black_scholes(S, K, T, r, sigma, option_type)
greeks = compute_greeks(S, K, T, r, sigma, option_type)
vega = greeks["vega"] * 100 # compute_greeks scales vega per 1%, undo that for the raw derivative
diff = price - market_price
if abs(diff) < tolerance:
return sigma
if vega == 0:
return None # can't converge — vega too small (deep ITM/OTM or near expiry)
sigma -= diff / vega # Newton-Raphson update step
return None # did not converge within max_iterations
Why Newton-Raphson can fail, and the fallback
Deep ITM/OTM options and options very near expiry have near-zero Vega — the price barely responds to volatility changes, so the solver has almost nothing to work with and can diverge or oscillate. A bisection method as a fallback is more robust (slower, but guaranteed to converge within a bounded range if a solution exists):
def implied_volatility_bisection(market_price: float, S, K, T, r, option_type: str = "CE",
low: float = 0.001, high: float = 5.0, tolerance: float = 1e-5, max_iterations: int = 100) -> float | None:
for _ in range(max_iterations):
mid = (low + high) / 2
price = black_scholes(S, K, T, r, mid, option_type)
if abs(price - market_price) < tolerance:
return mid
if price > market_price:
high = mid
else:
low = mid
return None
Building an IV surface — solving for every strike in the chain at once
def compute_iv_for_chain(chain: pd.DataFrame, S: float, T: float, r: float) -> pd.DataFrame:
chain = chain.copy()
chain["iv_call"] = chain.apply(
lambda row: implied_volatility(row["ltp_call"], S, row.name, T, r, "CE") or float("nan"), axis=1
)
chain["iv_put"] = chain.apply(
lambda row: implied_volatility(row["ltp_put"], S, row.name, T, r, "PE") or float("nan"), axis=1
)
return chain
(chain here is the option chain built in chapter 33, strike-indexed.)
The volatility smile/skew — why IV isn't flat across strikes
Plotting iv_call/iv_put against strike typically shows a "smile" or "skew" shape rather than a flat line — OTM puts commonly show higher IV than ATM (crash/tail-risk hedging demand pushes their price, and therefore implied vol, up) in most equity index markets, NIFTY included. This skew shape itself is tradeable information (e.g. via risk reversals and other skew-sensitive structures), distinct from simply reading the ATM IV level.
Practical use: comparing an option's IV to its own history (chapter 123)
The absolute IV number on its own doesn't tell you whether an option is "cheap" or "expensive" — that judgment requires comparing today's IV against the instrument's historical IV range, which chapter 123 builds directly on this chapter's output.
Next: 121 — Put-call parity