Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

vol-surface

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.


The finding

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:

  1. 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.
  2. 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.


Running it

Backend (Python 3.11+):

cd backend
python -m venv .venv
.venv/Scripts/python -m pip install -e ".[dev]"     # Windows
uvicorn vol_surface.api:app --reload

Frontend (Node 18+), in a second terminal:

cd frontend
npm install
npm run dev

Then 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.

Command-line report

The pipeline also runs standalone, and prints exactly what it filtered and why:

cd backend
.venv/Scripts/python -m vol_surface.cli SPY
SPY  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

Design notes

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.


Layout

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.

Tests

cd backend && pytest

211 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.


Limitations

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.

About

Interactive implied-volatility surface viewer. Computes IV in-house from option mids against a put-call-parity forward, because yfinance's own IV column fails put-call parity by 13 vol points at two years.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages