4. SSVI Implied Volatility Surface#

SSVI (Gatheral and Jacquier [2014]) parametrizes total implied variance across the surface with 3 global parameters \((\rho, \eta, \gamma)\) and per-maturity ATM variance \(\theta_T\):

\[w(k, \theta_T) = \frac{\theta_T}{2}\left(1 + \rho\,\varphi(\theta_T)\,k + \sqrt{(\varphi(\theta_T)\,k + \rho)^2 + (1-\rho^2)}\right)\]

where \(\varphi(\theta) = \frac{\eta}{\theta^\gamma(1+\theta)^{1-\gamma}}\) and \(k = \log(K/F)\).

Calibrate on live SPX options via cppfm.

Hide code cell source

import cppfm
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from pathlib import Path

from vol_data import load_latest_snapshot, fetch_spx_options, prepare_otm_slices

plt.style.use('ggplot')
plt.rcParams['figure.figsize'] = (12, 7)
plt.rcParams['text.usetex'] = False
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[1], line 7
      4 import matplotlib.pyplot as plt
      5 from pathlib import Path
----> 7 from vol_data import load_latest_snapshot, fetch_spx_options, prepare_otm_slices
      9 plt.style.use('ggplot')
     10 plt.rcParams['figure.figsize'] = (12, 7)

File ~/work/Quantitative-Finance-Book/Quantitative-Finance-Book/calibration/vol_data.py:8
      6 import numpy as np
      7 import pandas as pd
----> 8 import yfinance as yf
     11 def fetch_spx_options(ticker: str = "^SPX") -> tuple[float, pd.DataFrame]:
     12     """Download full option chain across all expirations. Returns (S0, raw_df)."""

ModuleNotFoundError: No module named 'yfinance'

Hide code cell source

r = 0.04  # approximate risk-free rate
SNAPSHOT_DIR = Path('snapshots')

try:
    S0, df = load_latest_snapshot(SNAPSHOT_DIR)
except FileNotFoundError:
    print("No snapshot found — fetching live...")
    S0, df = fetch_spx_options("^SPX")

print(f"S0 = {S0:.2f}")
print(f"Expirations with data: {df['T'].nunique()}")
df.head()
Loaded snapshot: spx_options_20260303_155315.csv  (fetched 20260303_155315)
S0 = 6730.42
Expirations with data: 51
contractSymbol lastTradeDate strike lastPrice bid ask change percentChange volume openInterest impliedVolatility inTheMoney contractSize currency optionType expiration T mid
0 SPXW260303C02800000 2026-03-03 15:11:06+00:00 2800.0 3936.00 3911.1 3927.4 -100.80005 -2.497029 1.0 0 0.00001 True REGULAR USD call 2026-03-03 -0.001926 3919.25
1 SPXW260303C04400000 2026-03-02 16:17:47+00:00 4400.0 2465.38 2309.7 2325.9 0.00000 0.000000 1.0 1 0.00001 True REGULAR USD call 2026-03-03 -0.001926 2317.80
2 SPXW260303C05400000 2026-03-02 15:31:59+00:00 5400.0 1448.59 1311.6 1327.4 0.00000 0.000000 4.0 4 0.00001 True REGULAR USD call 2026-03-03 -0.001926 1319.50
3 SPXW260303C05500000 2026-03-03 04:38:47+00:00 5500.0 1329.14 1209.7 1224.4 -30.27002 -2.226703 1.0 22 0.00001 True REGULAR USD call 2026-03-03 -0.001926 1217.05
4 SPXW260303C05800000 2026-03-02 19:49:39+00:00 5800.0 1097.92 912.3 927.7 0.00000 0.000000 3.0 3 0.00001 True REGULAR USD call 2026-03-03 -0.001926 920.00

Hide code cell source

slices = prepare_otm_slices(df, S0, r)
print(f"{len(slices)} maturity slices after filtering")

# summary table
summary = pd.DataFrame([{
    'T': s['T'],
    'F': f"{s['F']:.1f}",
    'n_strikes': len(s['strikes']),
    'K_range': f"[{s['strikes'][0]:.0f}, {s['strikes'][-1]:.0f}]",
    'k_range': f"[{s['k'][0]:.3f}, {s['k'][-1]:.3f}]"
} for s in slices])
summary
40 maturity slices after filtering
T F n_strikes K_range k_range
0 0.022714 6736.5 136 [3400, 7250] [-0.684, 0.073]
1 0.025452 6737.3 206 [3400, 7210] [-0.684, 0.068]
2 0.033666 6739.5 133 [3200, 7250] [-0.745, 0.073]
3 0.036404 6740.2 118 [3200, 7200] [-0.745, 0.066]
4 0.039142 6741.0 112 [3000, 7325] [-0.810, 0.083]
5 0.041879 6741.7 104 [3400, 7350] [-0.685, 0.086]
6 0.044617 6742.4 379 [2500, 7410] [-0.992, 0.094]
7 0.052831 6744.7 107 [2800, 7500] [-0.879, 0.106]
8 0.055569 6745.4 91 [2800, 7400] [-0.879, 0.093]
9 0.058306 6746.1 87 [2800, 7350] [-0.879, 0.086]
10 0.061044 6746.9 82 [2800, 7425] [-0.879, 0.096]
11 0.063782 6747.6 183 [2800, 7450] [-0.880, 0.099]
12 0.071996 6749.8 81 [2800, 7350] [-0.880, 0.085]
13 0.074734 6750.6 446 [2200, 7445] [-1.121, 0.098]
14 0.077471 6751.3 73 [2800, 7325] [-0.880, 0.082]
15 0.080209 6752.0 107 [4000, 7400] [-0.524, 0.092]
16 0.091161 6755.0 38 [4000, 7400] [-0.524, 0.091]
17 0.093899 6755.7 22 [4000, 7300] [-0.524, 0.077]
18 0.102112 6758.0 100 [2800, 8000] [-0.881, 0.169]
19 0.121277 6763.1 392 [1600, 8400] [-1.441, 0.217]
20 0.140442 6768.3 88 [3600, 7800] [-0.631, 0.142]
21 0.156869 6772.8 350 [2800, 8800] [-0.883, 0.262]
22 0.197937 6783.9 368 [1200, 9200] [-1.732, 0.305]
23 0.236267 6794.3 198 [2800, 8400] [-0.886, 0.212]
24 0.291024 6809.2 301 [1200, 9400] [-1.736, 0.322]
25 0.323878 6818.2 279 [2600, 8500] [-0.964, 0.220]
26 0.370421 6830.9 262 [1000, 10200] [-1.921, 0.401]
27 0.408751 6841.4 119 [2800, 8800] [-0.893, 0.252]
28 0.466246 6857.1 230 [1000, 10600] [-1.925, 0.436]
29 0.542906 6878.2 202 [600, 9400] [-2.439, 0.312]
30 0.575760 6887.2 269 [2800, 8600] [-0.900, 0.222]
31 0.619566 6899.3 182 [800, 9800] [-2.155, 0.351]
32 0.715391 6925.8 171 [800, 9400] [-2.158, 0.305]
33 0.792050 6947.1 203 [800, 10600] [-2.161, 0.423]
34 0.827643 6957.0 218 [2800, 8800] [-0.910, 0.235]
35 0.868710 6968.4 188 [400, 11400] [-2.858, 0.492]
36 0.964535 6995.2 146 [800, 10000] [-2.168, 0.357]
37 1.041195 7016.6 172 [800, 10200] [-2.171, 0.374]
38 1.287601 7086.1 200 [1000, 10800] [-1.958, 0.421]
39 1.788628 7229.6 132 [400, 12000] [-2.894, 0.507]

4.1. Per-Slice SVI#

SVI fitted independently per maturity — no cross-slice consistency.

Hide code cell source

svi_results = []
for s in slices:
    res = cppfm.calibrate_svi(
        s['strikes'].tolist(),
        s['ivs'].tolist(),
        float(s['F']),
        float(s['T'])
    )
    p = res.params
    svi_results.append({
        'T': s['T'],
        'a': p.a, 'b': p.b, 'rho': p.rho, 'm': p.m, 'sigma': p.sigma,
        'rmse': res.rmse,
        'constraints': p.satisfies_constraints(),
        'converged': res.converged
    })

svi_df = pd.DataFrame(svi_results)
svi_df.style.format({
    'T': '{:.4f}', 'a': '{:.6f}', 'b': '{:.6f}',
    'rho': '{:.4f}', 'm': '{:.4f}', 'sigma': '{:.6f}',
    'rmse': '{:.6f}'
})
  T a b rho m sigma rmse constraints converged
0 0.0227 -0.007820 0.040773 -0.8649 -0.1319 0.311474 0.000126 False False
1 0.0255 -0.012185 0.046202 -0.7727 -0.1231 0.371286 0.000120 False False
2 0.0337 -0.009424 0.046188 -0.9403 -0.1621 0.358919 0.000137 False True
3 0.0364 -0.012432 0.049704 -0.8528 -0.1364 0.380078 0.000136 False False
4 0.0391 -0.010864 0.049744 -0.8731 -0.1288 0.349662 0.000176 False True
5 0.0419 -0.074176 0.106509 -0.1710 0.0597 0.704510 0.000179 False False
6 0.0446 -0.020033 0.062352 -0.7090 -0.1202 0.428563 0.000317 False True
7 0.0528 -0.033368 0.077258 -0.4474 -0.0365 0.480028 0.000279 False True
8 0.0556 -0.125471 0.140830 -0.0494 0.1541 0.889251 0.000222 False False
9 0.0583 -0.147222 0.165763 0.1126 0.2880 0.892273 0.000215 False False
10 0.0610 -0.102609 0.149092 0.1137 0.2427 0.694377 0.000213 True True
11 0.0638 -0.013973 0.058995 -0.8796 -0.1178 0.375390 0.000222 False True
12 0.0720 -0.048721 0.090199 -0.4697 -0.0430 0.595032 0.000169 False True
13 0.0747 -0.097255 0.133051 -0.0850 0.1340 0.733227 0.000230 False True
14 0.0775 -0.041294 0.083819 -0.5826 -0.0724 0.572630 0.000154 False True
15 0.0802 -0.086287 0.117369 -0.3070 0.0187 0.760523 0.000165 False False
16 0.0912 -0.097491 0.126346 -0.4135 -0.0839 0.832744 0.000172 False False
17 0.0939 -0.072670 0.112320 -0.2830 0.0586 0.663290 0.000102 False False
18 0.1021 -0.188868 0.570892 0.7277 0.6362 0.487186 0.000273 True False
19 0.1213 -0.257239 0.451166 0.5924 0.6707 0.712487 0.000357 True False
20 0.1404 -0.146242 0.292218 0.4290 0.4017 0.562989 0.000227 True False
21 0.1569 -0.174216 0.393769 0.5550 0.4906 0.540389 0.000281 True False
22 0.1979 -0.270646 0.397479 0.4522 0.5464 0.773295 0.000690 True False
23 0.2363 -0.280855 0.642331 0.6618 0.6632 0.592476 0.000470 True False
24 0.2910 -0.192266 0.311312 0.2778 0.3458 0.661418 0.000630 True True
25 0.3239 -0.113139 0.205935 -0.0709 0.1250 0.581347 0.000894 True True
26 0.3704 -0.164567 0.256804 0.0492 0.2008 0.669908 0.000931 True True
27 0.4088 -0.086738 0.183604 -0.2845 0.0260 0.540580 0.001508 True True
28 0.4662 -0.182032 0.265752 -0.0110 0.1770 0.720677 0.001368 True True
29 0.5429 -0.554784 0.953005 0.6732 0.9133 0.802775 0.001922 True False
30 0.5758 0.009001 0.102179 -0.9990 -0.1147 0.185045 0.002913 True False
31 0.6196 -0.119212 0.216927 -0.2605 0.0421 0.633476 0.001877 True True
32 0.7154 -0.117258 0.218050 -0.3018 0.0230 0.640767 0.002511 True True
33 0.7921 -0.093726 0.203766 -0.3990 -0.0228 0.599176 0.002829 True True
34 0.8276 0.017274 0.112440 -0.9990 -0.1278 0.171071 0.005399 True False
35 0.8687 -0.090162 0.206647 -0.4197 -0.0410 0.591396 0.003641 True True
36 0.9645 -0.041219 0.175444 -0.6499 -0.1275 0.485075 0.005041 True True
37 1.0412 -0.004106 0.151789 -0.8350 -0.1596 0.353213 0.004886 True True
38 1.2876 0.011763 0.152370 -0.8910 -0.1913 0.315645 0.007550 True True
39 1.7886 -0.015015 0.192444 -0.7079 -0.1844 0.464155 0.011758 True True

Hide code cell source

n_slices = len(slices)
fig, axes = plt.subplots(
    (n_slices + 2) // 3, 3,
    figsize=(15, 4 * ((n_slices + 2) // 3)),
    squeeze=False
)

for i, (s, row) in enumerate(zip(slices, svi_results)):
    ax = axes[i // 3][i % 3]
    k_fine = np.linspace(s['k'].min() - 0.05, s['k'].max() + 0.05, 200)

    # reconstruct SVI params
    p = cppfm.SviParams(row['a'], row['b'], row['rho'], row['m'], row['sigma'])
    iv_fit = [p.implied_vol(ki, s['T']) for ki in k_fine]

    ax.plot(s['k'], s['ivs'], 'o', ms=3, label='market')
    ax.plot(k_fine, iv_fit, '-', lw=1.5, label='SVI')
    ax.set_title(f"T={s['T']:.3f}  RMSE={row['rmse']:.5f}")
    ax.set_xlabel('log-moneyness k')
    if i % 3 == 0:
        ax.set_ylabel('implied vol')

# hide unused subplots
for j in range(i + 1, axes.size):
    axes.flat[j].set_visible(False)

axes[0][0].legend()
fig.suptitle('SVI per-slice fit', y=1.01)
plt.tight_layout()
plt.show()
../_images/9f5cbb8a255783ebc6930fa88a3ddf1cff283caf88f5ff22a5bfeed9e5f1eca5.png

Lots of converged=False on short-dated slices — expected with few strikes and high curvature. Per-slice SVI doesn’t enforce cross-maturity consistency either. SSVI fixes both.

4.2. Global SSVI#

3 global parameters \((\rho, \eta, \gamma)\) + per-maturity \(\theta_T\). Cross-slice consistency enforced.

strikes_per_mat = [s['strikes'].tolist() for s in slices]
vols_per_mat = [s['ivs'].tolist() for s in slices]
forwards = [float(s['F']) for s in slices]
maturities = [float(s['T']) for s in slices]

ssvi_result = cppfm.calibrate_ssvi(strikes_per_mat, vols_per_mat, forwards, maturities)

p = ssvi_result.params
print(f"rho   = {p.rho:.6f}")
print(f"eta   = {p.eta:.6f}")
print(f"gamma = {p.gamma:.6f}")
print(f"RMSE  = {ssvi_result.rmse:.6f}")
print(f"converged     = {ssvi_result.converged}")
print(f"arbitrage_free = {ssvi_result.arbitrage_free}")

# per-maturity thetas
theta_df = pd.DataFrame({
    'T': maturities,
    'theta': ssvi_result.thetas,
    'ATM_vol': [np.sqrt(th / T) for th, T in zip(ssvi_result.thetas, maturities)]
})
theta_df
rho   = -0.492112
eta   = 1.908426
gamma = 0.483876
RMSE  = 0.012420
converged     = True
arbitrage_free = False
T theta ATM_vol
0 0.022714 0.001413 0.249402
1 0.025452 0.001526 0.244895
2 0.033666 0.001725 0.226365
3 0.036404 0.002082 0.239138
4 0.039142 0.002082 0.230624
5 0.041879 0.002339 0.236326
6 0.044617 0.002405 0.232151
7 0.052831 0.002563 0.220268
8 0.055569 0.002779 0.223629
9 0.058306 0.002865 0.221651
10 0.061044 0.002943 0.219557
11 0.063782 0.003125 0.221362
12 0.071996 0.003270 0.213117
13 0.074734 0.003434 0.214344
14 0.077471 0.003552 0.214133
15 0.080209 0.003676 0.214094
16 0.091161 0.003817 0.204616
17 0.093899 0.003834 0.202071
18 0.102112 0.004398 0.207537
19 0.121277 0.004903 0.201069
20 0.140442 0.005449 0.196966
21 0.156869 0.006148 0.197963
22 0.197937 0.007328 0.192409
23 0.236267 0.008400 0.188552
24 0.291024 0.010175 0.186988
25 0.323878 0.011145 0.185498
26 0.370421 0.012492 0.183639
27 0.408751 0.013988 0.184989
28 0.466246 0.015261 0.180916
29 0.542906 0.015261 0.167658
30 0.575760 0.018648 0.179970
31 0.619566 0.020890 0.183620
32 0.715391 0.024064 0.183407
33 0.792050 0.025041 0.177808
34 0.827643 0.025041 0.173943
35 0.868710 0.028788 0.182042
36 0.964535 0.032237 0.182817
37 1.041195 0.034500 0.182030
38 1.287601 0.042031 0.180673
39 1.788628 0.055979 0.176911

arbitrage_free = False — the sufficient condition \(\eta(1+|\rho|) \leq 2\) from Gatheral and Jacquier [2014] is violated. Sufficient condition though, not necessary — we might still be arbitrage-free. Check numerically below.

A few theta pairs are flat (monotonicity constraint binding where ATM total variance wanted to decrease).

Hide code cell source

fig, axes = plt.subplots(
    (n_slices + 2) // 3, 3,
    figsize=(15, 4 * ((n_slices + 2) // 3)),
    squeeze=False
)

params = ssvi_result.params
for i, s in enumerate(slices):
    ax = axes[i // 3][i % 3]
    k_fine = np.linspace(s['k'].min() - 0.05, s['k'].max() + 0.05, 200)

    # set theta for this maturity
    params.theta = ssvi_result.thetas[i]
    iv_fit = [params.implied_vol(ki, s['T']) for ki in k_fine]

    ax.plot(s['k'], s['ivs'], 'o', ms=3, label='market')
    ax.plot(k_fine, iv_fit, '-', lw=1.5, label='SSVI')
    ax.set_title(f"T={s['T']:.3f}")
    ax.set_xlabel('log-moneyness k')
    if i % 3 == 0:
        ax.set_ylabel('implied vol')

for j in range(i + 1, axes.size):
    axes.flat[j].set_visible(False)

axes[0][0].legend()
fig.suptitle(f'SSVI global fit  (RMSE={ssvi_result.rmse:.5f})', y=1.01)
plt.tight_layout()
plt.show()
../_images/b5e8cc9da8011dbf5c69fb25634d7faee1e8aa910f1d8ee5c5fe27685ec295c8.png

4.3. Arbitrage checks#

Two conditions for no static arbitrage: butterfly (density \(g(k) \geq 0\)) and calendar spread (total variance non-decreasing in \(T\)).

Hide code cell source

params = ssvi_result.params
k_grid = np.linspace(-1.5, 0.5, 300)

g_values = np.zeros((len(slices), len(k_grid)))
for i, s in enumerate(slices):
    params.theta = ssvi_result.thetas[i]
    for j, k in enumerate(k_grid):
        g_values[i, j] = params.g_function(k)

fig, ax = plt.subplots(figsize=(12, 6))
im = ax.imshow(
    g_values, aspect='auto', origin='lower',
    extent=[k_grid[0], k_grid[-1], 0, len(slices) - 1],
    cmap='RdYlGn', vmin=0
)
ax.set_xlabel('log-moneyness k')
ax.set_ylabel('maturity slice')
ax.set_yticks(range(len(slices)))
ax.set_yticklabels([f"{s['T']:.3f}" for s in slices])
plt.colorbar(im, label='g(k)')
ax.set_title('Butterfly density condition: g(k) >= 0')
plt.tight_layout()
plt.show()

print(f"min g(k) across surface: {g_values.min():.8f}")
../_images/40960a0728199f259988998b95c58fbb8d59510f15fbf9e8b8672ac005ea52df.png
min g(k) across surface: 0.24103993

Hide code cell source

# total variance at fixed moneyness levels across maturities
k_test = [-0.3, -0.1, 0.0, 0.1, 0.3]

fig, ax = plt.subplots()
for k_val in k_test:
    w_vals = []
    for i in range(len(slices)):
        params.theta = ssvi_result.thetas[i]
        w_vals.append(params.total_variance(k_val))
    ax.plot(maturities, w_vals, 'o-', label=f'k={k_val}')

ax.set_xlabel('maturity T')
ax.set_ylabel('total variance w(k, T)')
ax.set_title('Calendar spread: w should be non-decreasing in T')
ax.legend()
plt.tight_layout()
plt.show()
../_images/576d319abc3cacae9067369e8fb2fa75b675d403a00e6c4799f0702ef62e30db.png

Hide code cell source

params = ssvi_result.params
k_check = np.linspace(-1.5, 0.5, 500)

# global parameter constraint
eta_rho = params.eta * (1 + abs(params.rho))
print(f"eta*(1+|rho|) = {eta_rho:.3f}  (need <= 2 for Gatheral-Jacquier)")

diag_rows = []
for i, s in enumerate(slices):
    params.theta = ssvi_result.thetas[i]
    g_vals = np.array([params.g_function(k) for k in k_check])

    # calendar spread: w(k, T_i) should not exceed w(k, T_{i+1})
    if i > 0:
        w_curr = np.array([params.total_variance(k) for k in k_check])
        params.theta = ssvi_result.thetas[i - 1]
        w_prev = np.array([params.total_variance(k) for k in k_check])
        cal_violation = (w_prev - w_curr).max()
        params.theta = ssvi_result.thetas[i]
    else:
        cal_violation = 0.0

    diag_rows.append({
        'T': s['T'],
        'theta': ssvi_result.thetas[i],
        'min_g': g_vals.min(),
        'butterfly_ok': g_vals.min() >= -1e-10,
        'max_cal_viol': max(cal_violation, 0.0),
        'calendar_ok': cal_violation < 1e-10
    })

diag_df = pd.DataFrame(diag_rows)
print(f"Butterfly failures: {(~diag_df['butterfly_ok']).sum()}/{len(diag_df)}")
print(f"Calendar failures:  {(~diag_df['calendar_ok']).sum()}/{len(diag_df)}")
diag_df
eta*(1+|rho|) = 2.848  (need <= 2 for Gatheral-Jacquier)
Butterfly failures: 0/40
Calendar failures:  0/40
T theta min_g butterfly_ok max_cal_viol calendar_ok
0 0.022714 0.001413 0.248778 True 0.0 True
1 0.025452 0.001526 0.248711 True 0.0 True
2 0.033666 0.001725 0.248600 True 0.0 True
3 0.036404 0.002082 0.248418 True 0.0 True
4 0.039142 0.002082 0.248418 True 0.0 True
5 0.041879 0.002339 0.248299 True 0.0 True
6 0.044617 0.002405 0.248270 True 0.0 True
7 0.052831 0.002563 0.248201 True 0.0 True
8 0.055569 0.002779 0.248112 True 0.0 True
9 0.058306 0.002865 0.248077 True 0.0 True
10 0.061044 0.002943 0.248047 True 0.0 True
11 0.063782 0.003125 0.247977 True 0.0 True
12 0.071996 0.003270 0.247923 True 0.0 True
13 0.074734 0.003434 0.247864 True 0.0 True
14 0.077471 0.003552 0.247822 True 0.0 True
15 0.080209 0.003676 0.247780 True 0.0 True
16 0.091161 0.003817 0.247732 True 0.0 True
17 0.093899 0.003834 0.247726 True 0.0 True
18 0.102112 0.004398 0.247547 True 0.0 True
19 0.121277 0.004903 0.247397 True 0.0 True
20 0.140442 0.005449 0.247246 True 0.0 True
21 0.156869 0.006148 0.247064 True 0.0 True
22 0.197937 0.007328 0.246783 True 0.0 True
23 0.236267 0.008400 0.246550 True 0.0 True
24 0.291024 0.010175 0.246197 True 0.0 True
25 0.323878 0.011145 0.246019 True 0.0 True
26 0.370421 0.012492 0.245784 True 0.0 True
27 0.408751 0.013988 0.245539 True 0.0 True
28 0.466246 0.015261 0.245340 True 0.0 True
29 0.542906 0.015261 0.245340 True 0.0 True
30 0.575760 0.018648 0.244849 True 0.0 True
31 0.619566 0.020890 0.244546 True 0.0 True
32 0.715391 0.024064 0.244142 True 0.0 True
33 0.792050 0.025041 0.244023 True 0.0 True
34 0.827643 0.025041 0.244023 True 0.0 True
35 0.868710 0.028788 0.243581 True 0.0 True
36 0.964535 0.032237 0.243195 True 0.0 True
37 1.041195 0.034500 0.242951 True 0.0 True
38 1.287601 0.042031 0.242184 True 0.0 True
39 1.788628 0.055979 0.241040 True 0.0 True

4.4. Implied vol and local vol surfaces#

Hide code cell source

surface = ssvi_result.build_analytical_surface(forwards)

# implied vol surface
K_plot = np.linspace(S0 * 0.85, S0 * 1.15, 100)
T_plot = np.linspace(maturities[0], maturities[-1], 50)
K_mesh, T_mesh = np.meshgrid(K_plot, T_plot)

IV_mesh = np.zeros_like(K_mesh)
for i in range(K_mesh.shape[0]):
    for j in range(K_mesh.shape[1]):
        IV_mesh[i, j] = surface.implied_volatility(K_mesh[i, j], T_mesh[i, j])

fig = plt.figure(figsize=(12, 8))
ax = fig.add_subplot(111, projection='3d')
ax.plot_surface(K_mesh, T_mesh, IV_mesh, cmap='viridis', alpha=0.9)
ax.set_xlabel('Strike K')
ax.set_ylabel('Maturity T')
ax.set_zlabel('Implied Vol')
ax.set_title('SSVI Implied Volatility Surface')
ax.view_init(elev=25, azim=-50)
plt.tight_layout()
plt.show()
../_images/4eec3f273b694872e11487b7fa41dcdb8c16a8796a16c3a890c00e351e4c424e.png
# analytical Dupire local vol — no grid, no finite differences
S_plot = np.linspace(S0 * 0.8, S0 * 1.2, 100)
T_lv = np.linspace(maturities[0] + 0.01, maturities[-1] - 0.01, 50)
S_mesh, T_lv_mesh = np.meshgrid(S_plot, T_lv)

LV_mesh = np.zeros_like(S_mesh)
for i in range(S_mesh.shape[0]):
    for j in range(S_mesh.shape[1]):
        LV_mesh[i, j] = surface.local_volatility(S_mesh[i, j], T_lv_mesh[i, j])

fig = plt.figure(figsize=(12, 8))
ax = fig.add_subplot(111, projection='3d')
ax.plot_surface(S_mesh, T_lv_mesh, LV_mesh, cmap='inferno', alpha=0.9)
ax.set_xlabel('Spot S')
ax.set_ylabel('Time t')
ax.set_zlabel('Local Vol')
ax.set_title('Dupire Local Volatility Surface (analytical)')
ax.view_init(elev=25, azim=-50)
plt.tight_layout()
plt.show()
../_images/069f40ee0afc23ee5f4a412a2e14f92e4cf78c43d814c0beb9dae859ffb8af93.png