Yahoo hands you today's chain and nothing behind it. These four give you the history, for nothing, each with a real limit you should know before you build on it. Signup steps, runnable Python, and the output I got when I ran every block on 7 August 2026.
The reason I needed this data in the first place. Volatility trading and the greeks that sit past delta and gamma, worked on a live NVDA option chain rather than a toy example. Every number on screen came off a real chain, which is exactly the problem the four sources below solve.
Equity data is solved. You want ten years of SPY closes, one line of yfinance and you have it. Options are not solved. The moment you want the chain as it looked on a past date, the free tooling everybody uses simply stops working, and most people discover this halfway through building a backtest.
Here is the exact shape of the problem. This runs fine and returns nothing you can use for history.
import yfinance as yf
spy = yf.Ticker("SPY")
print(len(spy.options), spy.options[:3])
# 29 ('2026-08-10', '2026-08-11', '2026-08-12')
chain = spy.option_chain(spy.options[2])
print(chain.calls.columns.tolist())
# ['contractSymbol', 'lastTradeDate', 'strike', 'lastPrice', 'bid', 'ask', 'change',
# 'percentChange', 'volume', 'openInterest', 'impliedVolatility', 'inTheMoney',
# 'contractSize', 'currency']
Twenty nine expiries, greeks absent, implied vol present, and no argument anywhere that takes a quote date. The object is a live snapshot of the book right now. Ask again tomorrow and today is gone, permanently, because nothing is stored. That is why anyone doing serious options work either pays for a vendor feed or runs a cron job that snapshots the chain every evening and waits a year for a usable sample.
The four sources below skip that wait. I ordered them by how much friction stands between you and data on disk, so the first one needs no account at all and the last one needs a checkout.
| Source | Coverage | Greeks and IV | Account | The catch |
|---|---|---|---|---|
| DoltHub | 2,321 US symbols, 2019 to yesterday | yes | none | bulk pull needs the dolt CLI |
| Cboe | VIX daily, 1990 to yesterday | not applicable | none | index only, chains sit behind DataShop |
| Kaggle | SPY EOD, 2010 to 2023 | yes, precomputed | free, API token | one ticker, stops in 2023 |
| OptionsDX | 10 tickers, 2010 to 2023, EOD to minutely | yes | free, then checkout | one year per order, some variants paid |
Dolt is a SQL database with git semantics. Branches, commits, diffs, all of it, on tables instead of files. The repo post-no-preference/options is a community maintained mirror of the US options market kept on that engine, and it is public. There is a serious consequence hiding in that sentence. Because a Dolt repo speaks SQL over HTTP, you can query the entire options market from a laptop with nothing installed except requests.
option_chain and volatility_history.master, not main. Point the API at main and every query fails with branch not found, which is the single most common way people give up on this source.https://www.dolthub.com/api/v1alpha1/post-no-preference/options/master?q=<your SQL>. No key, no header, no account.dolt CLI and run dolt clone post-no-preference/options. After that a dolt pull brings each new day in. Expect tens of gigabytes.This is the script I would actually keep. It wraps the API in one function, pulls the SPY September expiry as it stood on 7 August 2026, and reads the 25 delta skew straight off the returned greeks. Every column below comes from the source, nothing is being estimated here.
import requests
import pandas as pd
API = "https://www.dolthub.com/api/v1alpha1/post-no-preference/options/master"
NUM = ["strike", "bid", "ask", "vol", "delta", "gamma", "vega"]
def dolt(sql: str) -> pd.DataFrame:
"""Run SQL against the public DoltHub repo. No auth, no rate limit key."""
r = requests.get(API, params={"q": sql}, timeout=60)
r.raise_for_status()
j = r.json()
if j["query_execution_status"] != "Success":
raise RuntimeError(j["query_execution_message"])
return pd.DataFrame(j["rows"])
def chain(symbol: str, date: str, expiry: str) -> pd.DataFrame:
df = dolt(f"""
SELECT date, expiration, strike, call_put, bid, ask, vol, delta, gamma, vega
FROM option_chain
WHERE act_symbol = '{symbol}' AND date = '{date}' AND expiration = '{expiry}'
ORDER BY strike, call_put
""")
df[NUM] = df[NUM].astype(float)
return df
df = chain("SPY", "2026-08-07", "2026-09-18")
calls, puts = df[df.call_put == "Call"], df[df.call_put == "Put"]
def nearest(frame, col, target):
return frame.loc[(frame[col] - target).abs().idxmin()]
atm = nearest(calls, "delta", 0.50)
c25 = nearest(calls, "delta", 0.25)
p25 = nearest(puts, "delta", -0.25)
print(f"{len(df)} quotes, {len(calls)} calls and {len(puts)} puts")
print(f"ATM strike {atm.strike:.0f} IV {atm.vol:.2%}")
print(f"25d call strike {c25.strike:.0f} IV {c25.vol:.2%}")
print(f"25d put strike {p25.strike:.0f} IV {p25.vol:.2%}")
print(f"25 delta skew: {(p25.vol - c25.vol) * 100:.2f} vol points")
| Point | Strike | Delta | Implied vol |
|---|---|---|---|
| 25 delta put | 750 | -0.2626 | 15.22% |
| At the money call | 773 | 0.5254 | 13.38% |
| 25 delta call | 796 | 0.2599 | 12.14% |
Three numbers and the smile is already there. The downside strike carries 3.08 vol points more than the equivalent upside strike, which is the equity skew doing exactly what it always does, put protection bid and call upside offered. You did not install anything, you did not sign anything, and you can loop that function over a date range to get the skew as a time series.
Heavy aggregates over the full table time out on the public endpoint. A COUNT(DISTINCT act_symbol) across option_chain returns context deadline exceeded, and that is the API protecting itself rather than a bug in your SQL. Work per symbol and per date, the way the primary key is laid out, and it is fast. If you need to sweep the whole market, that is what the clone is for. The lighter volatility_history table does answer those questions, and it is where the coverage numbers on this page come from: 2,321 distinct symbols, most recent date 7 August 2026.
Cboe publishes the full VIX series from its own CDN as a plain CSV. This is the exchange that computes the index, so this file is the primary source, not a redistribution. It goes back to 2 January 1990 and updates daily. One URL, no account, no key.
https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX_History.csv. Open it in a browser once to confirm it is four columns, DATE, OPEN, HIGH, LOW, CLOSE.VIX for another index ticker in that path to get its history. The same pattern serves the other Cboe volatility indices.The useful question is almost never the VIX level. It is the percentile, because a 15 handle means something different in 2017 than it does after a shock. This block answers it in about ten lines, straight from the URL, no local file.
import pandas as pd
URL = "https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX_History.csv"
vix = pd.read_csv(URL, parse_dates=["DATE"]).set_index("DATE").sort_index()
close = vix["CLOSE"]
last = close.iloc[-1]
five = close[close.index >= close.index[-1] - pd.Timedelta(days=1825)]
print(f"{len(close)} sessions, {close.index[0]:%Y-%m-%d} to {close.index[-1]:%Y-%m-%d}")
print(f"last close {last:.2f}")
print(f"percentile, 1990+ {(close < last).mean():.1%}")
print(f"percentile, 5y {(five < last).mean():.1%}")
print(f"mean {close.mean():.2f}, median {close.median():.2f}")
print(f"sessions above 30 {(close > 30).mean():.2%}")
print(close.nlargest(5).round(2))
| Measure | Value |
|---|---|
| Last close | 14.90 |
| Percentile since 1990 | 31.3% |
| Percentile, last 5 years | 20.2% |
| Mean, median | 19.44, 17.61 |
| Sessions closing above 30 | 7.95% |
| Highest close ever | 82.69 on 16 Mar 2020 |
Two percentiles that disagree, which is the whole point of computing both. Against the full 36 year record 14.90 sits at the 31st percentile, unremarkable. Against the last five years it is the 20th, quietly low. The 2008 and 2020 tails drag the long sample's distribution right, so the long lookback flatters the present. And 7.95% of all sessions closed above 30, so a regime most people file under rare is really one trading day in thirteen.
Worth keeping in view when you size a short vol trade off a backtest that happens to start in 2012.
The dataset is dudesurfin/spy-options-eod-volatility-surface-2010-2023, and full credit to whoever put it together, because assembling fourteen clean years of chains is not a weekend job. End of day SPY, one parquet file per year, roughly 600 MB total, greeks and implied vol already computed by the publisher. This is the set I trained the IV surface network on, and it is the fastest path to a real volatility surface that exists for free.
The layout is wide rather than long. One row per quote date, expiry and strike, carrying both sides at once, with bracketed column names that survive as [C_IV], [P_IV], [UNDERLYING_LAST] and so on. Strip the brackets on load or every column reference in your code becomes ugly.
kaggle.json.~/.kaggle/kaggle.json on Linux or macOS, or C:\Users\<you>\.kaggle\kaggle.json on Windows.pip install kagglehub pandas pyarrow. The pyarrow part is not optional, pandas cannot read parquet without an engine.import kagglehub
path = kagglehub.dataset_download(
"dudesurfin/spy-options-eod-volatility-surface-2010-2023"
)
print(path) # local cache dir holding spy_eod_2010.parquet ... spy_eod_2023.parquet
# CLI equivalent, if you prefer:
# kaggle datasets download -d dudesurfin/spy-options-eod-volatility-surface-2010-2023
You do not have to touch the API at all if you would rather not. Open the dataset page, hit Download, and you get the whole thing as one zip. Or expand the Data Explorer on the right and pull individual files, which is what I would do first. There are 14 of them, spy_eod_2010.parquet through spy_eod_2023.parquet, one per year. Grab a single year, check the columns are what you expect, then decide whether you want the other 13. No point pulling 600 MB to find out the schema does not suit you.
Any surface plot will look impressive here, so instead let me use the data for something you cannot do with a live feed at all. February 2018, the week short volatility blew up and XIV was wound down. This pulls the at the money implied vol on the four sessions around it, which is a question only historical chains can answer.
import pandas as pd
from pathlib import Path
df = pd.read_parquet(Path(path) / "spy_eod_2018.parquet")
df.columns = [c.strip().strip("[]") for c in df.columns] # the bracket tax
for c in ["UNDERLYING_LAST", "STRIKE", "DTE", "C_IV", "P_IV"]:
df[c] = pd.to_numeric(df[c], errors="coerce")
def atm_iv(day: str, lo: int, hi: int) -> pd.Series:
"""At the money implied vol for the expiry closest to the middle of [lo, hi] days."""
d = df[df.QUOTE_DATE.astype(str).str.startswith(day)].copy()
w = d[d.DTE.between(lo, hi)]
w = w[w.DTE == w.DTE.iloc[(w.DTE - (lo + hi) / 2).abs().argsort().iloc[0]]]
return w.loc[(w.STRIKE - w.UNDERLYING_LAST).abs().idxmin()]
for day in ["2018-02-02", "2018-02-05", "2018-02-06", "2018-02-09"]:
r30, r60 = atm_iv(day, 20, 40), atm_iv(day, 50, 70)
print(f"{day} spot {r30.UNDERLYING_LAST:7.2f} "
f"30d call IV {r30.C_IV:6.2%} 60d call IV {r60.C_IV:6.2%}")
| Session | Spot | 30d call IV | 60d call IV | 30d put IV |
|---|---|---|---|---|
| Fri 2 Feb | 275.52 | 14.99% | 14.61% | 13.18% |
| Mon 5 Feb | 263.93 | 33.94% | 30.23% | 18.31% |
| Tue 6 Feb | 269.17 | 21.53% | 18.80% | 20.53% |
| Fri 9 Feb | 261.70 | 22.25% | 19.66% | 21.84% |
Thirty day at the money vol went from 15 to 34 in one session and gave most of it back the next day, while the sixty day point moved far less. That is the term structure inverting under stress and then repairing, and it is the single clearest picture of why short dated volatility is a different asset from long dated volatility.
Now look at the last column on Monday. Call IV says 33.94% and put IV says 18.31% at essentially the same strike. That is not a market view, it is a data artifact. On a violently trending day the two sides of an end of day snapshot are stamped against an underlying that has already moved, and put call parity breaks in the file. Treat it as the standing warning for every free EOD source: take the out of the money side of each strike, drop quotes with a wide relative spread, and never trust a single row on a day the market gapped.
OptionsDX sells historical chains and gives a slice of them away. Ten tickers are listed, SPY, SPX, VIX, QQQ, TSLA, AAPL, NVDA, UVXY, SLV and BTC on Deribit, each covering 2010 to 2023, at five quote frequencies from end of day down to minutely. The product description is specific about what is inside, all expirations and strikes, greeks, implied volatility, bid, ask, last, and the underlying price.
Two things to be straight about. Every product on the shop is priced as a range starting at zero, so some year and frequency combinations are free and others are not. Check the price after you pick, not before. And the order is one year at a time, delivered as monthly CSVs, so fourteen years of SPY is fourteen separate trips through checkout.
.txt extension, comma separated, with bracketed headers.Do not hardcode column names against a vendor file you re-download every year. This loader normalises whatever headers arrive, finds the IV and greek columns by pattern, and melts the wide format into one row per option quote with the liquidity filters already applied. It is the same shape I use on the Kaggle parquet, because both arrive wide with a call block and a put block per strike.
import glob
import numpy as np
import pandas as pd
DTE_MIN, DTE_MAX = 7, 365 # skip 0 DTE noise and the sparse LEAPS tail
IV_MIN, IV_MAX = 0.03, 2.00 # kill negatives and the 40.0 blow-ups
MAX_REL_SPREAD = 0.50 # drop a quote if (ask - bid) / mid exceeds this
def load_optionsdx(pattern: str) -> pd.DataFrame:
"""Read every monthly file matching `pattern` into one long, filtered frame."""
frames = [pd.read_csv(f, skipinitialspace=True) for f in sorted(glob.glob(pattern))]
df = pd.concat(frames, ignore_index=True)
df.columns = [c.strip().strip("[]").upper() for c in df.columns]
base = pd.DataFrame({
"date": pd.to_datetime(df["QUOTE_DATE"]),
"expiry": pd.to_datetime(df["EXPIRE_DATE"]),
"dte": pd.to_numeric(df["DTE"], errors="coerce"),
"spot": pd.to_numeric(df["UNDERLYING_LAST"], errors="coerce"),
"strike": pd.to_numeric(df["STRIKE"], errors="coerce"),
})
out = []
for prefix, kind in (("C", "call"), ("P", "put")):
bid = pd.to_numeric(df[f"{prefix}_BID"], errors="coerce")
ask = pd.to_numeric(df[f"{prefix}_ASK"], errors="coerce")
mid = (bid + ask) / 2.0
side = base.assign(
type=kind,
mid=mid,
iv=pd.to_numeric(df[f"{prefix}_IV"], errors="coerce"),
delta=pd.to_numeric(df.get(f"{prefix}_DELTA"), errors="coerce"),
rel_spread=(ask - bid) / mid.replace(0, np.nan),
)
# keep only the out of the money side, where the IV is cleanest
otm = side.strike > side.spot if kind == "call" else side.strike < side.spot
out.append(side[otm])
q = pd.concat(out, ignore_index=True)
q = q[q.dte.between(DTE_MIN, DTE_MAX)
& q.iv.between(IV_MIN, IV_MAX)
& q.rel_spread.le(MAX_REL_SPREAD)
& q.mid.gt(0)]
q["log_m"] = np.log(q.strike / q.spot)
q["tau"] = q.dte / 365.0
return q.reset_index(drop=True)
quotes = load_optionsdx("spy_eod_2023*.txt")
print(quotes.shape)
print(quotes.groupby("type").iv.describe()[["count", "mean", "50%"]])
To check it holds up I pointed it at a full year of SPY end of day in the same wide layout, swapping read_csv for read_parquet and changing nothing else. 548,099 quotes survive the filters, and the split is the reason you keep both sides rather than averaging them.
| Side | Quotes | Mean IV | Median IV |
|---|---|---|---|
| Calls, above spot | 181,880 | 13.89% | 13.58% |
| Puts, below spot | 366,219 | 26.79% | 24.00% |
Twice as many put quotes as call quotes survive, and they carry roughly double the implied vol. That is the skew again, now as a population rather than three strikes, and it is the reason a surface fit that ignores option type will underfit the downside where all the interesting risk sits.
Four filters carry almost all the value here. Out of the money only, because a deep in the money quote is mostly intrinsic value and its implied vol is numerically unstable. Relative spread capped, because a quote nobody would trade is not a price. Implied vol bounded, because free files contain rows where the solver failed and left a 40.0 sitting in the column. Days to expiry bounded, because the zero DTE rows behave like a different instrument.
Skip those four and your surface fit will spend its capacity learning the junk rather than the smile. I have made that mistake, and the giveaway is a model whose worst errors all sit in the deep wings.
| If you want to | Use | Because |
|---|---|---|
| Track skew on one name over time | DoltHub | greeks in the table, query by symbol and date, no install |
| Fit a volatility surface today | Kaggle | fourteen clean years, IV precomputed, one download |
| Screen the whole market | DoltHub cloned | 2,321 symbols locally, the public API will time out |
| Study a specific name other than SPY | OptionsDX | ten tickers, and intraday if the variant is free |
| Put a vol level in context | Cboe | 36 years of VIX from the exchange that computes it |
| Get today's chain | yfinance | still the fastest snapshot, just store it yourself |
The honest summary is that none of this replaces a paid feed. Coverage stops in 2023 on two of the four, the crypto book is a different market from equity options, and nobody is offering you free tick data on a single name of your choosing. What you do get is enough to build a surface, measure a skew, test a variance premium idea, and find out whether the strategy is worth paying for data before you pay for data.
One habit to steal regardless of which source you pick. Start a nightly snapshot of the chains you care about today, even if you never look at it. Options history is the one dataset you cannot buy back cheaply, and a year from now the cron job you set up tonight is the only sample nobody else has.
The volatility surface network trained on the Kaggle set: two neural nets learn the SPY IV surface
The greeks that live on these chains: vanna, volga, color, zomma and speed