Fit an SPY option chain to an implied volatility surface that is arbitrage-free by theorem, and measure exactly what that guarantee costs you in fit quality.
Live demo: https://codeebytee.github.io/02-vol-surface-svi/ (enable Pages: Settings → Pages → main /docs)
Run the interface locally: clone the repo and open docs/index.html in any browser. No install, no server, no internet — Plotly is vendored in docs/vendor/. requirements.txt is only needed to re-run the research.
- Turns a raw listed option chain into clean implied vols. Nine SPY expiries from 7 days to 1.9 years, forward and discount factor implied from put–call parity rather than assumed, quotes filtered through a seven-stage funnel, then inverted to IV with Brent over a branched rational initial guess.
- Calibrates two models and puts them head to head. Raw SVI fitted per slice via the Zeliade quasi-explicit scheme (outer Nelder–Mead over
(m, σ), inner constrained linear least squares), and SSVI fitted globally with a power-lawφ(θ)whose parameter bounds make the no-arbitrage theorems hold by construction. - Checks for static arbitrage instead of assuming it away. Butterfly via the Durrleman function
g(k)scanned directly on a 241-point grid, plus the sufficient conditions; calendar via total-variance crossing. The page shows the violations in the raw chain and the fit that removes them.
Fitting each expiry independently is 3.1× more accurate and completely unusable:
| Model | Free parameters | RMSE (vol points) | Butterfly-free | Calendar-free |
|---|---|---|---|---|
| Per-slice raw SVI | 45 | 1.25 | no — 3 of 9 slices | no — 165/1928 grid cells |
| Global SSVI | 12 | 3.86 | yes | yes |
Three of the nine independently-fitted slices imply a negative probability density inside the quoted strike range, and the fitted slices cross in total variance in the wings — a calendar spread with a negative price. SSVI gives up 2.6 volatility points of fit and returns a surface you can actually differentiate for local vol or hand to a Monte Carlo. That trade-off is the result, and neither number is the "right" answer; the choice depends on whether you are marking a book or pricing an exotic off the surface.
The raw chain itself contains 649 negative-cost butterflies. This is not free money — the prints in one expiry were not observed at the same instant, and a non-simultaneous price set violates convexity for mechanical reasons. It is the argument for imposing no-arbitrage on the fit rather than inheriting it from the data.
pip install -r requirements.txt # 1. research dependencies (interface needs none)
python scripts/build_frontend.py # 2. re-runs the whole pipeline, writes docs/data.js
pytest -q # 3. 92 testsbuild_frontend.py uses the committed chain snapshot in data/, so it is fully offline and reproducible. Pass --refresh to pull a fresh chain from yfinance instead.
src/models/ black.py (reflection-stable Black-76), implied_vol.py (Brent + rational guess),
svi.py (quasi-explicit calibration), ssvi.py (global fit), arbitrage.py
(Durrleman g, density, calendar), surface.py
src/data/ option_chain.py (yfinance → cache → synthetic fallback), filters.py
(the seven-stage funnel), forward.py (parity regression)
scripts/ build_frontend.py (regenerates docs/data.js), make_results.py, make_gif.py
docs/ index.html — the whole interface, one file; data.js — generated, never hand-edited
tests/ 92 tests including JS/Python parity checks on the ported math
results/ model_comparison.csv, svi_fits.csv, synthetic_recovery.csv, figures/
notebooks/ vol_surface_research.ipynb — the story, importing from src/
config.yaml every tunable number in the project
New to the topic? Start with PREREQUISITES.md. Already know the finance and want to judge the work? DEEP_DIVE.md.
- Arbitrage-free by parameterisation, not by post-hoc repair. The SSVI bounds on
(ρ, η, γ)are chosen so Gatheral–Jacquier's sufficient conditions hold for every point the optimiser can reach. The alternative — fit freely, then project onto the admissible set — is easier to code and gives no guarantee at all about the region between your knots. The cost of the honest version is a visible 2.6 vol points, reported rather than hidden. - The forward is measured, not assumed. Every slice gets its own
(F, D)from a put–call parity regression over the near-the-money strikes. Assuming a rate and a dividend yield instead pushes the entire error into skew: a forward that is wrong by 0.3% looks exactly like a smile that is tilted, and you would then calibrateρto your own bad guess. - The split between Python and JS follows the cost of the math, not convenience. Calibration, inversion and the arbitrage scan are expensive and run in Python; the fitted surface, the smile,
g(k)and the implied density are closed-form in the SVI parameters, so they are ported to JS and recompute live on every slider move — including the five raw-SVI sliders in the Slice Explorer, which let you break the surface yourself and watch the density go negative.tests/test_js_parity.pypins the two implementations to each other so the port cannot silently drift.
All nine expiries in the committed snapshot were built from recent traded prints rather than two-sided quotes, because the free feed publishes a zero book outside regular trading hours. Prints are worse data than quotes — not simultaneous, not necessarily at mid — and they inflate the apparent arbitrage count. Every slice is labelled on the page with the price source it used. The 1.25-vs-3.86 comparison is unaffected since both models see the same quotes, but absolute error would be lower on a live book. SPY options are also American, and the ignored early-exercise premium biases deep put IVs slightly upward; SPX would remove this. Both points, and five more, are in §7 of DEEP_DIVE.md.
MIT — see LICENSE.
