Volatility Surface API: Build and Visualize an IV Surface in Python | FlashAlpha Research

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.


Tomasz Dobrowolski - Quant Engineer

  • #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:

SVI Parameterization (Raw) $$ w(k) = a + b \left( \rho (k - m) + \sqrt{(k - m)^2 + \sigma^2} \right) $$

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).

When to Use FlashAlpha's SVI vs Your Own

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 Playground

Related Reading


Live Market Pulse

Get tick-by-tick visibility into market shifts with full-chain analytics streaming in real time.

Intelligent Screening

Screen millions of option pairs per second using your custom EV rules, filters, and setups.

Execution-Ready

Instantly send structured orders to Interactive Brokers right from your scan results.

Join the Community

Discord

Engage in real time conversations with us!

Twitter / X

Follow us for real-time updates and insights!

GitHub

Explore our open-source SDK, examples, and analytics resources!