Building the unique local volatility surface implied by SPY's option market, and measuring what flat-vol pricing costs on a barrier option: 32 basis points of spot.
Live demo: https://codeebytee.github.io/04-local-vol-dupire/ (enable Pages: Settings → Pages → main /docs)
Run the interface locally: clone the repo and double-click docs/index.html.
No install, no server, no internet connection needed — the page ships its own
copy of Plotly and computes everything in the browser. requirements.txt is only
for re-running the research.
- Builds a local volatility surface from a real SPY option chain via Dupire's equation, in both the implied-vol form (used) and the call-price form (implemented in order to measure why nobody uses it).
- Prices a barrier option under that surface with a Rannacher-started Crank–Nicolson PDE and an independent Brownian-bridge Monte Carlo, and against three flat-volatility conventions a desk might actually use.
- Quantifies the smile effect on exotics in basis points — the number a trader would act on, rather than a picture of two surfaces side by side.
One-year down-and-out call on SPY, struck at spot, barrier 10% below (chain as of 2026-08-05, spot 771.33):
| Method | Price | vs local vol |
|---|---|---|
| Local volatility (Crank–Nicolson PDE) | 53.897 | — |
| Local volatility (Monte Carlo, 200k paths) | 53.943 ± 0.130 | +0.35 se |
| Flat vol @ ATM — the usual shortcut | 56.351 | −31.8 bp of spot (−4.36%) |
| Flat vol @ strike | 57.570 | −47.6 bp (−6.38%) |
| Flat vol @ barrier | 61.049 | −92.7 bp (−11.72%) |
Every flat-vol convention overprices the contract, by between 32 and 93bp of spot against a bid-offer of perhaps 20–40bp. Under a negative skew, volatility rises as spot falls, so paths heading toward the barrier are more volatile than a flat pricer assumes, they knock out more often, and the option is worth less.
Two other measured results:
- Local vol is 2.0–2.1× as steep in log-moneyness as the implied vol it came from, at 3, 9 and 18 months. The textbook rule of thumb, reproduced from a real chain.
- Smoothing cannot rescue a raw surface. Across five orders of magnitude of smoothing parameter, the fraction of grid points where local vol is undefined bottoms out at 2.1% and never reaches zero — the smoother just trades butterfly violations for calendar ones.
pip install -r requirements.txt
python scripts/make_results.py # the whole study -> results/ (~2.5 min)
python scripts/build_frontend.py # results/ -> docs/data.jspytest runs the 27-test suite in about a minute. python scripts/check_page.py
drives the finished page in headless Chrome from file:// and cross-checks its
JavaScript against the Python library.
docs/index.html the interface — single file, opens offline, no build step
docs/data.js generated by scripts/build_frontend.py, never hand-edited
src/models/dupire.py Dupire in both forms; start here
src/models/ssvi.py the arbitrage-free implied surface it differentiates
src/models/pde.py Crank–Nicolson, Rannacher start, barrier on a grid node
src/models/mc.py Brownian-bridge Monte Carlo
src/models/smoothing.py the lambda scan — the case against regularisation
src/models/validation.py fixed point, round trip, convergence, closed forms
scripts/make_results.py runs everything, writes results/local_vol_results.json
scripts/refresh_chain.py refetches data/chain_snapshot.json (needs yfinance)
notebooks/ the story in four figures
tests/ 27 tests: the surface, then the engines
config.yaml every tunable number in the project
New to options? → PREREQUISITES.md, written for a
software engineer with no finance background.
Want the equations and the validation tables? →
DEEP_DIVE.md.
- Fit worse on purpose. Per-slice SVI fits the quotes to 1.25 vol points with 45 parameters; SSVI manages only 3.86 with 12. SSVI wins anyway, because the raw chain contains 649 butterfly arbitrage violations and a closer fit inherits them. Dupire's denominator is the no-butterfly condition, so an arbitraged input surface produces a local vol that does not exist. Zero of 6,305 grid points are invalid under SSVI.
- The browser runs the model, not a slideshow. Because SSVI is closed form, so are its derivatives, so the entire local vol surface is analytic apart from one 1-D spline. Every slider move rebuilds the surface from scratch and re-solves a 241 × 200 barrier PDE in JavaScript. Only the things with an optimiser in them — the calibration, the λ-scan, the validation tables — are precomputed, and each such panel says so on the page.
- Broken points stay visibly broken. Where the local vol is undefined the
code returns
NaNand counts it, rather than clipping it into a plausible number. A floored local vol reaches a PDE solver silently; a hole in the surface is a diagnosis. The interface has a "break it" preset that pushes the input surface until the local vol stops existing, and shows you where.
| Check | Result |
|---|---|
| Flat surface in ⟹ flat surface out | exact to machine precision (7,381 points) |
| Forward-PDE round trip: IV → local vol → prices → IV | 6.98 bp of vol RMSE |
| PDE and MC vs Reiner–Rubinstein closed form | PDE within 0.05 bp of spot; MC within 1.2 se |
| PDE vs MC on the real surface (no closed form) | 0.35 se apart |
| PDE convergence order | 1.96 (theory: 2) |
| Browser JS vs Python library | local variance 2.8e-15; barrier PDE 6.0e-6 relative |
Exchange-delayed SPY quotes from yfinance, snapshotted 2026-08-05 and
committed to data/ so the repo reproduces without a network call. The as-of
date is displayed on the page. Quotes are filtered for two-sided markets,
relative spread, and moneyness before anything is fitted; the page shows the
funnel. If no snapshot and no network are available, the code falls back to a
fixed-seed synthetic chain that is labelled as synthetic wherever it appears.
All barrier prices assume continuous monitoring unless the monitoring control
says otherwise; the discrete-monitoring correction is worth +15.0 bp of spot on
the base contract and is reported rather than buried.
MIT — see LICENSE.
