Volatility Surface API: Build and Visualize an IV Surface in Python
Build a complete implied volatility surface in Python using the FlashAlpha API. Visualize the vol surface in 3D, plot skew curves and term structure, and fit an SVI parametric model — all with real market data.
- #VolatilitySurface #ImpliedVolatility #SVI #Python #QuantFinance
What Is a Volatility Surface
A volatility surface is a three-dimensional mapping: strike price on one axis, time to expiration on the other, and implied volatility on the vertical. Every point on this surface represents the market's consensus IV for a specific option contract.
The vol surface encodes everything the options market knows: directional skew (downside protection demand), term structure (near-term event pricing vs long-term uncertainty), and the curvature of the smile at each expiry. It's not a derived metric — the vol surface IS the options market. Option prices, Greeks, exotic valuations, and risk management all flow from it.
If you're building pricing models, scanning for relative value across strikes and expirations, or fitting stochastic vol models, you need a clean volatility surface. This tutorial shows you how to build one.
Why You'd Build One
- Relative value: Identify which strikes and expirations are cheap or rich relative to the fitted surface — these are the mispricings
- Skew analysis: Track how the skew shape changes over time — steepening skew signals increasing demand for downside protection
- Pricing models: Feed into local vol, stochastic vol (Heston, SABR), or any model that requires a continuous IV function
- Risk management: Stress test portfolios by shocking the surface — parallel shift, skew steepening, term structure inversion
- Backtesting: Reconstruct historical surfaces to backtest vol strategies with proper IV data, not just price data
Every institutional desk — market makers, hedge funds, prop firms — maintains a vol surface for every underlier they trade. Now you can build one with an API call and 30 lines of Python.
Pull the Raw Data
The Option Quote endpoint returns the full chain with IV and SVI-smoothed vol for every contract. Extract the data into a DataFrame:
from flashalpha import FlashAlphaClient
import pandas as pd
from datetime import datetime
client = FlashAlphaClient(api_key="your_api_key")
chain = client.get_option_chain("SPY")
df = pd.DataFrame(chain)
df["dte"] = (pd.to_datetime(df["expiry"]) - datetime.now()).dt.days
# Filter to calls (put surface is analogous), positive DTE, reasonable strikes
spot = df["mid"].iloc[0] # approximate spot from near-ATM mid
calls = df[
(df["type"] == "C") &
(df["dte"] > 0) &
(df["dte"] <= 180) &
(df["implied_vol"] > 0.01) &
(df["open_interest"] > 100)
].copy()
print(f"Loaded {len(calls)} contracts across {calls['expiry'].nunique()} expirations")
print(calls[["expiry", "dte", "strike", "implied_vol", "svi_vol", "delta", "open_interest"]].head(15))
Output:
Loaded 2847 contracts across 14 expirations
expiry dte strike implied_vol svi_vol delta open_interest
2026-03-20 5 550.0 0.2145 0.2138 0.8921 12340
2026-03-20 5 555.0 0.2034 0.2028 0.8654 15670
2026-03-20 5 560.0 0.1956 0.1951 0.8312 8920
2026-03-20 5 565.0 0.1897 0.1893 0.7891 21450
2026-03-20 5 570.0 0.1854 0.1850 0.7389 34560
2026-03-20 5 575.0 0.1823 0.1820 0.6812 28900
2026-03-20 5 580.0 0.1798 0.1795 0.6178 42100
2026-03-20 5 585.0 0.1785 0.1782 0.5501 38760
2026-03-20 5 590.0 0.1790 0.1788 0.4802 45000
2026-03-20 5 595.0 0.1876 0.1871 0.4102 31200
2026-03-20 5 600.0 0.1923 0.1918 0.3412 27800
2026-03-20 5 605.0 0.1989 0.1983 0.2756 19500
2026-03-20 5 610.0 0.2078 0.2071 0.2145 14200
2026-03-20 5 615.0 0.2189 0.2181 0.1598 10800
2026-03-20 5 620.0 0.2321 0.2312 0.1123 8400
Notice both implied_vol (raw market IV) and svi_vol (SVI-smoothed) columns. The SVI fit removes noise from illiquid strikes while preserving the true skew shape — it's what you want for surface construction.
Build the Surface Matrix
Pivot the data into a strike x DTE matrix with IV as the values:
import numpy as np
# Pivot: rows = strikes, columns = DTE, values = SVI-smoothed IV
surface = calls.pivot_table(
values="svi_vol",
index="strike",
columns="dte",
aggfunc="mean"
)
# Drop strikes with too many missing expirations, then interpolate gaps
surface = surface.dropna(thresh=surface.shape[1] // 2)
surface = surface.interpolate(method="linear", axis=0).interpolate(method="linear", axis=1)
surface = surface.dropna()
print(f"Surface grid: {surface.shape[0]} strikes x {surface.shape[1]} expirations")
print(f"Strike range: {surface.index.min()} - {surface.index.max()}")
print(f"DTE range: {surface.columns.min()} - {surface.columns.max()} days")
print(f"\nSample (ATM region):")
print((surface.iloc[len(surface)//2 - 3 : len(surface)//2 + 4] * 100).round(2))
Visualize — 3D Surface Plot
The money shot — a full 3D implied volatility surface:
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
strikes = surface.index.values
dtes = surface.columns.values
X, Y = np.meshgrid(dtes, strikes)
Z = surface.values * 100 # Convert to percentage
fig = plt.figure(figsize=(14, 9))
ax = fig.add_subplot(111, projection="3d")
surf = ax.plot_surface(X, Y, Z, cmap="viridis", alpha=0.85, edgecolor="none",
rstride=1, cstride=1, antialiased=True)
ax.set_xlabel("Days to Expiration", fontsize=11, labelpad=10)
ax.set_ylabel("Strike", fontsize=11, labelpad=10)
ax.set_zlabel("Implied Volatility (%)", fontsize=11, labelpad=10)
ax.set_title("SPY Implied Volatility Surface (SVI-Smoothed)", fontsize=13, pad=20)
ax.view_init(elev=25, azim=235)
fig.colorbar(surf, shrink=0.5, aspect=10, label="IV (%)")
plt.tight_layout()
plt.savefig("spy_vol_surface_3d.png", dpi=150, bbox_inches="tight")
plt.show()
The skew is clearly visible: OTM puts (lower strikes) trade at higher IV than OTM calls (higher strikes) — this is the classic equity skew driven by institutional demand for downside hedges. The term structure slopes upward in the back months, pricing more uncertainty further out.
Visualize — 2D Skew Curves (Multiple Expirations)
Overlay IV vs strike for several expirations on one chart to compare skew shapes across the term structure:
fig, ax = plt.subplots(figsize=(12, 6))
# Pick 4-5 representative expirations
expiries_to_plot = sorted(surface.columns.tolist())
selected = [expiries_to_plot[i] for i in range(0, len(expiries_to_plot), max(1, len(expiries_to_plot) // 5))][:5]
colors = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6"]
for dte, color in zip(selected, colors):
data = surface[dte].dropna()
ax.plot(data.index, data.values * 100, "-o", color=color, label=f"{dte}d DTE",
markersize=3, linewidth=1.5, alpha=0.8)
ax.set_xlabel("Strike", fontsize=11)
ax.set_ylabel("Implied Volatility (%)", fontsize=11)
ax.set_title("SPY IV Skew — Multiple Expirations", fontsize=13)
ax.legend(loc="upper right")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("spy_skew_curves.png", dpi=150)
plt.show()
Comparing skew across expirations reveals where relative value lives. If the 30-day skew is steeper than the 90-day skew, near-term downside protection is relatively expensive — a calendar spread on puts could exploit this differential.
Visualize — Term Structure (ATM IV Across Expirations)
Plot ATM implied volatility for each expiration date to see the term structure:
# Find ATM strike (closest to spot for each DTE)
atm_iv_by_dte = []
for dte in sorted(surface.columns):
col = surface[dte].dropna()
if len(col) == 0:
continue
# Find strike closest to the middle of the available range (proxy for ATM)
mid_strike = col.index[len(col) // 2]
atm_iv_by_dte.append({"dte": dte, "atm_iv": col[mid_strike] * 100})
term = pd.DataFrame(atm_iv_by_dte)
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(term["dte"], term["atm_iv"], "o-", color="#3b82f6", linewidth=2, markersize=6)
ax.fill_between(term["dte"], term["atm_iv"], alpha=0.1, color="#3b82f6")
ax.set_xlabel("Days to Expiration", fontsize=11)
ax.set_ylabel("ATM Implied Volatility (%)", fontsize=11)
ax.set_title("SPY ATM IV Term Structure", fontsize=13)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("spy_term_structure.png", dpi=150)
plt.show()
A flat term structure means the market expects consistent vol across all horizons. An upward-sloping (contango) curve is the normal state — more uncertainty further out. An inverted (backwardation) curve signals near-term event premium — earnings, FOMC, or macro stress pricing into front expirations.
The Volatility Analysis endpoint also returns the term structure state directly (contango, backwardation, or mixed) so you can detect this programmatically without building the surface.
Advanced: SVI Parametric Fitting
FlashAlpha already provides SVI-smoothed IV (svi_vol) in every option quote response, so you don't need to fit SVI yourself for most use cases. But if you're building your own models or want to understand the parameterization, here's the approach.
The SVI (Stochastic Volatility Inspired) model, introduced by Jim Gatheral, parameterizes the implied variance as a function of log-moneyness:
Where w is implied total variance (IV² × T), k is log-moneyness ln(K/F), and the five parameters are:
- a — overall variance level
- b — slope of the wings (controls how steep the smile is)
- ρ — rotation/skew parameter (-1 to 1, negative = equity skew)
- m — horizontal translation (shifts the smile left/right)
- σ — smoothness of the ATM region
from scipy.optimize import minimize
import numpy as np
def svi_total_variance(k, a, b, rho, m, sigma):
"""SVI raw parameterization: total implied variance as a function of log-moneyness."""
return a + b * (rho * (k - m) + np.sqrt((k - m)**2 + sigma**2))
def fit_svi_slice(strikes, ivs, forward, tte):
"""Fit SVI to a single expiration slice."""
k = np.log(strikes / forward) # log-moneyness
market_var = (ivs ** 2) * tte # total variance
def objective(params):
a, b, rho, m, sigma = params
model_var = svi_total_variance(k, a, b, rho, m, sigma)
return np.sum((model_var - market_var) ** 2)
# Constraints: b >= 0, -1 < rho < 1, sigma > 0, a + b*sigma*sqrt(1-rho^2) >= 0
bounds = [(-0.5, 0.5), (0.001, 2.0), (-0.999, 0.999), (-0.5, 0.5), (0.001, 2.0)]
x0 = [market_var.mean(), 0.1, -0.5, 0.0, 0.1]
result = minimize(objective, x0, bounds=bounds, method="L-BFGS-B")
return result.x, result.fun
# Example: fit SVI to the nearest expiry slice
nearest_dte = sorted(calls["dte"].unique())[1] # skip weeklies
slice_data = calls[calls["dte"] == nearest_dte].sort_values("strike")
strikes = slice_data["strike"].values
ivs = slice_data["implied_vol"].values
forward = strikes[len(strikes) // 2] # approximate forward
tte = nearest_dte / 365.0
params, residual = fit_svi_slice(strikes, ivs, forward, tte)
a, b, rho, m, sigma = params
print(f"SVI Parameters ({nearest_dte}d expiry):")
print(f" a = {a:.6f} (variance level)")
print(f" b = {b:.6f} (wing slope)")
print(f" rho = {rho:.4f} (skew)")
print(f" m = {m:.6f} (translation)")
print(f" sigma = {sigma:.6f} (ATM smoothness)")
print(f" Residual: {residual:.8f}")
# Plot: market IV vs SVI fit
k_fine = np.linspace(np.log(strikes.min() / forward), np.log(strikes.max() / forward), 200)
svi_var = svi_total_variance(k_fine, *params)
svi_iv = np.sqrt(svi_var / tte) * 100
fig, ax = plt.subplots(figsize=(10, 5))
ax.scatter(strikes, ivs * 100, color="#ef4444", s=20, label="Market IV", zorder=3)
ax.scatter(strikes, slice_data["svi_vol"].values * 100, color="#22c55e", s=20, label="FlashAlpha SVI", zorder=3)
strikes_fine = forward * np.exp(k_fine)
ax.plot(strikes_fine, svi_iv, "-", color="#3b82f6", linewidth=2, label="Local SVI Fit")
ax.set_xlabel("Strike")
ax.set_ylabel("Implied Volatility (%)")
ax.set_title(f"SPY SVI Fit — {nearest_dte}d Expiry")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("spy_svi_fit.png", dpi=150)
plt.show()
The SVI fit should closely match both the market IV and FlashAlpha's pre-computed SVI — they're using the same Gatheral parameterization under the hood. The advantage of fitting locally is that you can then extrapolate to strikes where no liquid market exists, or use the parameters to detect arbitrage (butterfly spread violations, calendar spread violations).
For most applications — visualization, screening, relative value scanning — use the pre-computed svi_vol from the API. Fit your own SVI only if you need: custom parameter constraints, extrapolation beyond listed strikes, inter-slice arbitrage detection, or feeding parameters into exotic pricing models.
Pre-Built Surface Endpoint
If you don't need to build the surface yourself, the /v1/surface/{symbol} endpoint returns a pre-computed volatility surface grid — ready to visualize or feed into models without any data processing:
surface_data = client.get_surface("SPY")
# Returns a cached grid: strikes x expirations x SVI-smoothed IV
# Ready for direct visualization or model input
This is the fastest path if you just need the surface for visualization or downstream analytics. Build your own from raw chain data when you need full control over filtering, interpolation, or parameterization.
Rate Limits and Pricing
| Plan | Requests/Day | Surface Access | Price |
|---|---|---|---|
| Growth | 2,500 | Option chain (build your own surface) + /v1/volatility | $299/mo |
| Pro | Unlimited | All above + /v1/surface + SVI parameters + backtesting | $1,499/mo |
Building a surface for one ticker uses a single API call (the full chain). Scanning surfaces across 50 tickers daily fits comfortably in the Growth plan. See full pricing.
Build Your Own Vol Surface
Per-strike IV, SVI-smoothed vol, and pre-built surfaces for 4,000+ tickers.
Get API Key → Try the PlaygroundRelated Reading
- Implied Volatility API — pull per-strike IV data for any ticker
- SVI Deep Dive — theory and calibration of arbitrage-free volatility surfaces
- IV Rank Scanner — find elevated IV across your watchlist
- IV Crush Explained — track how earnings reshape the surface overnight
- Interactive Vol Surface Tool — visualize any ticker's surface without code