An interactive implied-volatility surface viewer. Pick an underlying, see IV across strikes and expiries as a 3D surface, and read the smile/skew at any single expiry.
The interesting part is not the plot. It is that the volatilities are computed here rather than taken from the data feed — because the feed's own numbers turn out to be wrong in a specific, measurable way.
yfinance ships an impliedVolatility column. It is tempting to plot it
directly. Put-call parity says you shouldn't.
Parity forces a call and a put on the same strike and expiry to imply the same volatility. It uses no volatility model, so it can judge any IV column impartially. On SPY, the vendor's column disagrees with itself — and the disagreement is signed, and grows with maturity:
| days to expiry | vendor call IV − put IV | gap / √T | in-house |
|---|---|---|---|
| 20 | +0.43% | +1.83% | +0.00% |
| 111 | +4.25% | +7.71% | +0.18% |
| 292 | +7.99% | +8.93% | +0.27% |
| 839 | +13.25% | +8.74% | +0.99% |
Two facts identify the cause:
- Recovering the forward from parity on mid prices alone — no volatility model involved, so it can't inherit the vendor's error — gives a forward growing at +3.05%/year above spot. That's the short rate (~3.8%) minus SPY's dividend yield (~1%). Exactly right.
- The gap scales as √T (the middle column flattens out). A constant forward error produces precisely that scaling. Random quote noise doesn't.
The vendor is solving against forward = spot, ignoring cost of carry. Calls
come out too high, puts too low, by 13 vol points at two years — roughly
±6.6 a side on a ~20% vol.
Why it's easy to miss: the at-the-money average is fine. Averaging the call and put cancels the error almost exactly, so the vendor's ATM term structure looks textbook and completely believable. The error only surfaces on a surface built the standard way — OTM puts below the money, OTM calls above — where the bias flips sign at the money and renders as a growing discontinuous step. A fabricated skew artefact, in exactly the region a vol surface exists to show.
Inverting against the parity-implied forward collapses it:
| symbol | vendor near / long | in-house near / long |
|---|---|---|
| SPY | 0.43% / 6.32% — fails | 0.03% / 0.24% — passes |
| AAPL | 1.88% / 8.70% — fails | 0.07% / 0.19% — passes |
| NVDA | 1.10% / 8.44% — fails | 0.07% / 0.09% — passes |
Full write-up, including the pipeline order that makes this work:
backend/API.md.
Backend (Python 3.11+):
cd backend
python -m venv .venv
.venv/Scripts/python -m pip install -e ".[dev]" # Windows
uvicorn vol_surface.api:app --reloadFrontend (Node 18+), in a second terminal:
cd frontend
npm install
npm run devThen open http://localhost:5173.
A FRED API key is optional — set FRED_API_KEY to use the keyed endpoint,
otherwise the keyless CSV endpoint is used, falling back to a documented
constant. Every response states which source it used.
The pipeline also runs standalone, and prints exactly what it filtered and why:
cd backend
.venv/Scripts/python -m vol_surface.cli SPYSPY spot 769.35 as of 2026-08-29 03:28 UTC
expiries: 27 fetched of 27 available
contracts: 9588 raw -> 8351 kept
risk-free: 3.840% from fred_csv as of 2026-08-27
forwards: 27/27 measured from put-call parity, implied carry +3.05%/yr
FILTER SUMMARY (rules applied in order, first match wins)
rule dropped share why
no_two_sided_market 654 6.8% no bid or no ask, so no reliable mid to invert
no_liquidity 170 1.8% no volume and no open interest
iv_uninvertible 413 4.3% no Black-Scholes vol reproduces the mid price
iv_out_of_band 0 0.0% inverted vol outside the configured sanity band
KEPT 8351 87.1%
PUT-CALL PARITY TEST (|K/S-1| <= 2%, model-free)
IV column near-dated long-dated verdict
vendor (yfinance) 0.43% 6.32% FAILS
in-house inversion 0.03% 0.24% passes
VERDICT: vendor IV biased long-dated; in-house inversion corrects it
Every hole in the surface has a name. Cleaning rules run in order, first match wins, and each dropped contract is charged to exactly one of them. Rejected rows are kept alongside the survivors. The UI's data-quality panel renders the tally, so "why is that strike missing" always has an answer.
Quotes with no time value are refused, not answered. All of an option's vol information lives in its time value. Once a price is intrinsic to machine precision, a wide range of vols reproduces it identically and any answer is the solver's arbitrary landing point. Returning a confident number there would put invented data on the chart.
Interpolation runs along each smile, never across expiries. Near-dated vol
is genuinely noisier than far-dated — earnings, event risk, a weekend of decay
against a two-day expiry. That's a real feature, not something to smooth away.
Cells outside an expiry's observed strike range stay null and render as
holes rather than extrapolated values.
The liquidity floor is OR, not AND. A contract survives if it traded today or someone holds it. Requiring both costs ~4% more contracts, concentrated in the far wings of long-dated expiries — precisely where the skew lives. The report always prints what the strict rule would have cost.
backend/ FastAPI service, the vol maths, and the CLI report
frontend/ React + Plotly.js viewer (surface / smile / term structure)
Independent tooling on each side; the API contract is documented in
backend/API.md.
cd backend && pytest211 tests, no network. Fixtures are synthetic chains generated from Black-76 at a known forward and known smile, so a failure means the code changed, not that the market moved. Pricing is anchored to published Black-Scholes values (Hull ex. 15.6, the standard 10.4506 / 5.5735 case, and others) so a plausible-looking refactor can't quietly change the maths.
Documented in full in backend/API.md.
The main ones: options on these names are American while the inversion is
European (the error is confined to deep in-the-money contracts, none of which
reach the OTM-only surface); year fractions are calendar-day; and the skew
readout is a fixed-moneyness proxy rather than a true 25-delta risk reversal.
Free data sources only — yfinance option chains and the FRED 3-month T-bill. Portfolio piece, not trading infrastructure. Not investment advice.