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.