From option chain to arbitrage-free surface¶
One complete, runnable walkthrough: raw call/put quotes with bid/ask spreads, through ingestion and calendar-aware calibration, to a verified surface you can evaluate, price, audit, and persist.
This page uses a synthetic chain so every number is reproducible in the docs. For the same pipeline on real market data — a live SPY chain fetched via yfinance, with the no-lookahead snapshot discipline spelled out — see Real-data examples.
1. A realistic synthetic chain¶
Both legs quoted at every strike, three expiries, bid/ask spreads. Replace this block with your own market data.
import numpy as np
import pandas as pd
from py_vollib.black import black
from pysvi import svi_total_variance
rows = []
r = 0.02
true_params = {"a": 0.01, "b": 0.12, "rho": -0.6, "m": 0.01, "sigma": 0.25}
for T in (0.25, 0.5, 1.0):
F = 100.0 * np.exp(r * T)
k = np.linspace(-0.25, 0.25, 21)
iv = np.sqrt(svi_total_variance(k, **true_params) * (T / 0.25) / T)
for ki, vi in zip(k, iv):
K = F * np.exp(ki)
for flag in ("c", "p"):
px = black(flag, F, K, T, r, float(vi))
spread = max(0.02, 0.01 * px)
rows.append({"strike": K, "expiry": T, "cp": flag,
"bid": max(px - spread / 2, 1e-3), "ask": px + spread / 2})
raw = pd.DataFrame(rows)
2. Ingest¶
OptionChain does the preprocessing: mids, implied forwards from put-call parity, OTM leg selection, Black-76 IV inversion, bid/ask IV bands. Invalid quotes are dropped and counted.
from pysvi import OptionChain
chain = OptionChain.from_dataframe(raw, rate=r, spot=100.0)
chain.panel.head() # strike, iv, maturity, implied_forward, iv_bid, iv_ask
Rates may be a flat float or a term structure T -> r(T); put-call-parity forwards embed dividends automatically, and spot/dividend_yield only serve as the forward fallback for expiries without put-call pairs.
3. Calibrate, calendar-aware¶
surface = chain.fit(model="svi", enforce_calendar=True,
initialization="multi_start")
enforce_calendar chains each slice into the next slice’s NO_CALENDAR penalty automatically; multi_start protects against raw SVI’s local minima. Any model name works here ("ssvi" for a butterfly-free-by-construction surface, "essvi" for a jointly fitted term structure, …).
4. Audit¶
Nothing about the fit is hidden: the report records what went in, what was rejected, how well every slice fitted, and whether the result is arbitrage-free on the quoted range.
print(surface.diagnose())
SurfaceFitReport
================
Model: SVI Backend: numba
Objective: total_variance Loss: l2
Initialization: multi_start Calendar: enforced
Slices: 3 ok / 0 failed or rejected
Quotes: 63 in / 63 used
T status quotes used iv RMSE max|res| k-range
0.25 ok 21 21 2.34e-06 5.30e-06 [-0.250, +0.250]
0.5 ok 21 21 5.95e-07 1.71e-06 [-0.250, +0.250]
1 ok 21 21 4.75e-07 1.04e-06 [-0.250, +0.250]
Fitted 2026-10-11T16:00:00Z | svi-py 1.0.0
Arbitrage diagnostics
=====================
Slice (T=0.25):
Butterfly arbitrage: none (min g = 2.716e-01 at k = -0.7500)
Lee wing bounds: satisfied (left slope = 0.1921, right slope = 0.0482, bound = 2, asymptotic)
Slice (T=0.5):
Butterfly arbitrage: none (min g = 2.188e-01 at k = -0.7500)
Lee wing bounds: satisfied (left slope = 0.3840, right slope = 0.0961, bound = 2, asymptotic)
Slice (T=1):
Butterfly arbitrage: none (min g = 9.982e-02 at k = -0.7500)
Lee wing bounds: satisfied (left slope = 0.7681, right slope = 0.1921, bound = 2, asymptotic)
Calendar arbitrage: none (min dw = 3.400e-02 at k = 0.1969, between T=0.25 and T=0.5)
Overall: ARBITRAGE-FREE
Overall: OK
surface.fit_report.ok is False whenever any slice of the input panel failed to calibrate, so partial surfaces cannot pass silently.
5. Evaluate and price¶
surface.iv(100.0, 0.5) # implied vol at an absolute strike
surface.iv(100.0, 0.7) # interpolated maturity
surface.atm_vol(0.5), surface.skew(0.5)
surface.price(95.0, 0.7, "put") # Black-76 on the slice forward
surface.delta(95.0, 0.7, "put")
6. Persist¶
Calibrating is expensive; evaluating is cheap. Save once, distribute, reload exactly:
surface.save("spx_surface.json")
from pysvi import VolSurface
reloaded = VolSurface.load("spx_surface.json") # bitwise-identical evaluation
The file is versioned JSON (schema_version 1) carrying the model, per-slice parameters and forwards, the interpolation method, and the full fit report, so the provenance travels with the surface.