Yahoo te da la cadena de hoy y nada detrás. Estas cuatro te dan el histórico, gratis, cada una con un límite real que conviene conocer antes de construir encima. Pasos de registro, Python ejecutable y la salida que obtuve al correr cada bloque el 7 de agosto de 2026.
La razón por la que necesité estos datos en primer lugar. Trading de volatilidad y las griegas que van más allá de delta y gamma, trabajadas sobre una cadena de opciones real de NVDA y no sobre un ejemplo de juguete. Cada número en pantalla salió de una cadena real, que es justamente el problema que resuelven las cuatro fuentes de abajo.
Noventa y cuatro características de empresa alimentan una red neuronal con tres capas ocultas, entrenada con ventanas expansivas para que la validación nunca vea el futuro. Las predicciones ordenan cada acción, luego se compra el decil superior y se vende el decil inferior. La selección del modelo se hace con el coeficiente de información en lugar del error cuadrático medio, que aguanta mejor con pocos datos. El método viene de Gu, Kelly y Xiu (2020), Empirical Asset Pricing via Machine Learning, reconstruido aquí con datos gratuitos en lugar de las 30.000 acciones y 920 variables del original. El último paso conecta el portafolio a la API de Interactive Brokers mediante un script de Python, así la estrategia envía sus propias órdenes long short en vez de imprimir un backtest. El código y el notebook están en GitHub.
Los datos de acciones están resueltos. Quieres diez años de cierres de SPY, una línea de yfinance y los tienes. Las opciones no están resueltas. En cuanto quieres la cadena tal como se veía en una fecha pasada, las herramientas gratuitas que todos usan simplemente dejan de funcionar, y la mayoría lo descubre a mitad de armar un backtest.
Este es el problema exacto. Esto corre sin errores y no devuelve nada útil para histórico.
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']
Veintinueve vencimientos, sin griegas, con volatilidad implícita, y ningún argumento que acepte una fecha de cotización. El objeto es una foto en vivo del libro en este momento. Pregunta de nuevo mañana y el día de hoy desapareció para siempre, porque no se guarda nada. Por eso quien trabaja en serio con opciones o paga un feed de un proveedor o corre un cron job que guarda la cadena cada noche y espera un año hasta tener una muestra útil.
Las cuatro fuentes de abajo se saltan esa espera. Las ordené según cuánta fricción hay entre tú y los datos en disco: la primera no necesita cuenta y la última necesita pasar por el checkout.
| Fuente | Cobertura | Griegas y VI | Cuenta | La trampa |
|---|---|---|---|---|
| DoltHub | 2.321 símbolos de EE. UU., de 2019 a ayer | sí | ninguna | la descarga masiva requiere el CLI de dolt |
| Cboe | VIX diario, de 1990 a ayer | no aplica | ninguna | solo el índice, las cadenas están en DataShop |
| Kaggle | SPY EOD, de 2010 a 2023 | sí, precalculadas | gratis, token de API | un solo ticker, termina en 2023 |
| OptionsDX | 10 tickers, de 2010 a 2023, de EOD a minuto | sí | gratis, luego checkout | un año por pedido, algunas variantes son pagas |
Dolt es una base de datos SQL con semántica de git. Ramas, commits, diffs, todo, sobre tablas en lugar de archivos. El repo post-no-preference/options es un espejo del mercado de opciones de EE. UU. mantenido por la comunidad sobre ese motor, y es público. Esa frase esconde una consecuencia importante. Como un repo de Dolt habla SQL por HTTP, puedes consultar todo el mercado de opciones desde una laptop sin instalar nada más que requests.
option_chain y volatility_history.master, no main. Apunta la API a main y todas las consultas fallan con branch not found, que es la razón más común por la que la gente abandona esta fuente.https://www.dolthub.com/api/v1alpha1/post-no-preference/options/master?q=<your SQL>. Sin clave, sin header, sin cuenta.dolt y ejecuta dolt clone post-no-preference/options. Después, un dolt pull trae cada día nuevo. Calcula decenas de gigabytes.Este es el script que realmente guardaría. Envuelve la API en una función, trae el vencimiento de septiembre de SPY tal como estaba el 7 de agosto de 2026 y lee el skew de 25 delta directamente de las griegas devueltas. Cada columna de abajo viene de la fuente, aquí no se estima nada.
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")
| Punto | Strike | Delta | Volatilidad implícita |
|---|---|---|---|
| Put de 25 delta | 750 | -0.2626 | 15.22% |
| Call at the money | 773 | 0.5254 | 13.38% |
| Call de 25 delta | 796 | 0.2599 | 12.14% |
Tres números y la sonrisa ya está ahí. El strike a la baja lleva 3,08 puntos de volatilidad más que el strike equivalente al alza, que es el skew de acciones haciendo lo de siempre: protección con puts demandada y upside con calls ofrecido. No instalaste nada, no firmaste nada, y puedes recorrer esa función sobre un rango de fechas para obtener el skew como serie de tiempo.
Las agregaciones pesadas sobre la tabla completa exceden el tiempo en el endpoint público. Un COUNT(DISTINCT act_symbol) sobre option_chain devuelve context deadline exceeded, y es la API protegiéndose, no un error en tu SQL. Trabaja por símbolo y por fecha, como está organizada la clave primaria, y es rápido. Si necesitas barrer todo el mercado, para eso está el clon. La tabla volatility_history, más liviana, sí responde esas preguntas, y de ahí salen los números de cobertura de esta página: 2.321 símbolos distintos, fecha más reciente 7 de agosto de 2026.
Cboe publica la serie completa del VIX desde su propio CDN como un CSV simple. Es la bolsa que calcula el índice, así que este archivo es la fuente primaria, no una redistribución. Arranca el 2 de enero de 1990 y se actualiza a diario. Una URL, sin cuenta, sin clave.
https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX_History.csv. Ábrelo una vez en el navegador para confirmar que tiene cuatro columnas, DATE, OPEN, HIGH, LOW, CLOSE.VIX por otro ticker de índice en esa ruta para obtener su historial. El mismo patrón sirve para los demás índices de volatilidad de Cboe.La pregunta útil casi nunca es el nivel del VIX. Es el percentil, porque un VIX de 15 no significa lo mismo en 2017 que después de un shock. Este bloque lo responde en unas diez líneas, directo desde la URL, sin archivo local.
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))
| Medida | Valor |
|---|---|
| Último cierre | 14.90 |
| Percentil desde 1990 | 31.3% |
| Percentil, últimos 5 años | 20.2% |
| Media, mediana | 19.44, 17.61 |
| Sesiones con cierre por encima de 30 | 7.95% |
| Cierre más alto de la historia | 82,69 el 16 de mar de 2020 |
Dos percentiles que no coinciden, y justamente por eso conviene calcular ambos. Frente al registro completo de 36 años, 14,90 está en el percentil 31, nada llamativo. Frente a los últimos cinco años está en el 20, discretamente bajo. Las colas de 2008 y 2020 desplazan la distribución de la muestra larga hacia la derecha, así que la ventana larga favorece al presente. Y el 7,95% de todas las sesiones cerró por encima de 30, así que un régimen que la mayoría considera raro es en realidad una de cada trece sesiones.
Vale la pena tenerlo presente cuando dimensionas una posición corta en volatilidad a partir de un backtest que casualmente empieza en 2012.
El dataset es dudesurfin/spy-options-eod-volatility-surface-2010-2023, y todo el crédito para quien lo armó, porque reunir catorce años limpios de cadenas no es trabajo de un fin de semana. SPY al cierre del día, un archivo parquet por año, unos 600 MB en total, con griegas y volatilidad implícita ya calculadas por quien lo publica. Es el conjunto con el que entrené la red de superficie de IV, y es el camino más rápido que existe para tener gratis una superficie de volatilidad real.
El formato es ancho, no largo. Una fila por fecha de cotización, vencimiento y strike, con ambos lados a la vez, y nombres de columna entre corchetes que quedan como [C_IV], [P_IV], [UNDERLYING_LAST], etc. Quita los corchetes al cargar o cada referencia a columnas en tu código se vuelve fea.
kaggle.json.~/.kaggle/kaggle.json en Linux o macOS, o en C:\Users\<you>\.kaggle\kaggle.json en Windows.pip install kagglehub pandas pyarrow. La parte de pyarrow no es opcional: pandas no puede leer parquet sin un motor.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
Si prefieres, no necesitas tocar la API. Abre la página del dataset, presiona Download y obtienes todo en un solo zip. O despliega el Data Explorer a la derecha y baja archivos individuales, que es lo que yo haría primero. Son 14, de spy_eod_2010.parquet a spy_eod_2023.parquet, uno por año. Toma un solo año, verifica que las columnas sean las que esperas y después decide si quieres los otros 13. No tiene sentido bajar 600 MB para descubrir que el esquema no te sirve.
Cualquier gráfico de superficie se verá impresionante aquí, así que prefiero usar los datos para algo que no puedes hacer con un feed en vivo. Febrero de 2018, la semana en que la volatilidad corta explotó y XIV fue liquidado. Esto extrae la volatilidad implícita at the money de las cuatro sesiones alrededor, una pregunta que solo las cadenas históricas pueden responder.
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%}")
| Sesión | Spot | IV call 30d | IV call 60d | IV put 30d |
|---|---|---|---|---|
| Vie 2 feb | 275.52 | 14.99% | 14.61% | 13.18% |
| Lun 5 feb | 263.93 | 33.94% | 30.23% | 18.31% |
| Mar 6 feb | 269.17 | 21.53% | 18.80% | 20.53% |
| Vie 9 feb | 261.70 | 22.25% | 19.66% | 21.84% |
La volatilidad at the money a treinta días pasó de 15 a 34 en una sesión y devolvió la mayor parte al día siguiente, mientras que el punto de sesenta días se movió mucho menos. Es la estructura temporal invirtiéndose bajo estrés y luego recomponiéndose, y es la imagen más clara de por qué la volatilidad de corto plazo es un activo distinto de la de largo plazo.
Ahora mira la última columna del lunes. La IV de la call dice 33,94% y la de la put 18,31%, prácticamente en el mismo strike. Eso no es una visión de mercado, es un artefacto de los datos. En un día de tendencia violenta, los dos lados de una foto de cierre quedan registrados contra un subyacente que ya se movió, y la paridad put call se rompe en el archivo. Tómalo como advertencia permanente para toda fuente EOD gratuita: usa el lado out of the money de cada strike, descarta cotizaciones con spread relativo amplio y nunca confíes en una sola fila en un día en que el mercado abrió con gap.
OptionsDX vende cadenas históricas y regala una parte. Lista diez tickers, SPY, SPX, VIX, QQQ, TSLA, AAPL, NVDA, UVXY, SLV y BTC en Deribit, cada uno de 2010 a 2023, en cinco frecuencias de cotización, desde cierre del día hasta por minuto. La descripción del producto es precisa sobre el contenido: todos los vencimientos y strikes, griegas, volatilidad implícita, bid, ask, último precio y el precio del subyacente.
Dos cosas para ser claros. Cada producto de la tienda tiene un precio en rango que empieza en cero, así que algunas combinaciones de año y frecuencia son gratis y otras no. Revisa el precio después de elegir, no antes. Y se pide un año a la vez, entregado en CSV mensuales, así que catorce años de SPY son catorce pasadas por el checkout.
.txt, separados por comas y con encabezados entre corchetes.No fijes nombres de columna en el código contra un archivo de proveedor que vuelves a descargar cada año. Este cargador normaliza los encabezados que lleguen, encuentra las columnas de IV y griegas por patrón y convierte el formato ancho en una fila por cotización de opción con los filtros de liquidez ya aplicados. Es la misma estructura que uso con el parquet de Kaggle, porque ambos llegan en formato ancho con un bloque de calls y uno de puts por 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%"]])
Para comprobar que funciona, lo apliqué a un año completo de SPY al cierre en el mismo formato ancho, cambiando read_csv por read_parquet y nada más. Pasan los filtros 548.099 cotizaciones, y la división es la razón por la que conservas ambos lados en vez de promediarlos.
| Lado | Cotizaciones | IV media | IV mediana |
|---|---|---|---|
| Calls, por encima del spot | 181,880 | 13.89% | 13.58% |
| Puts, por debajo del spot | 366,219 | 26.79% | 24.00% |
Sobreviven el doble de cotizaciones de puts que de calls, y llevan aproximadamente el doble de volatilidad implícita. Es otra vez el skew, ahora como población y no como tres strikes, y es la razón por la que un ajuste de superficie que ignora el tipo de opción va a subajustar la parte baja, justo donde está todo el riesgo interesante.
Cuatro filtros aportan casi todo el valor aquí. Solo fuera del dinero, porque una cotización muy dentro del dinero es casi todo valor intrínseco y su volatilidad implícita es numéricamente inestable. Spread relativo con tope, porque una cotización que nadie operaría no es un precio. Volatilidad implícita acotada, porque los archivos gratuitos tienen filas donde el solver falló y dejó un 40,0 en la columna. Días al vencimiento acotados, porque las filas de cero DTE se comportan como otro instrumento.
Si te saltas esos cuatro, tu ajuste de superficie gastará su capacidad aprendiendo basura en lugar de la sonrisa. Yo cometí ese error, y la señal es un modelo cuyos peores errores están todos en las alas profundas.
| Si quieres | Usa | Porque |
|---|---|---|
| Seguir el skew de un activo en el tiempo | DoltHub | griegas en la tabla, consulta por símbolo y fecha, sin instalar nada |
| Ajustar una superficie de volatilidad hoy | Kaggle | catorce años limpios, IV ya calculada, una sola descarga |
| Filtrar todo el mercado | DoltHub clonado | 2.321 símbolos en local, la API pública se queda sin tiempo |
| Estudiar un activo concreto distinto de SPY | OptionsDX | diez tickers, e intradía si la variante es gratuita |
| Poner en contexto un nivel de volatilidad | Cboe | 36 años de VIX del mismo mercado que lo calcula |
| Obtener la cadena de hoy | yfinance | sigue siendo la foto más rápida, solo guárdala tú mismo |
El resumen honesto es que nada de esto reemplaza un feed pago. La cobertura termina en 2023 en dos de las cuatro, el libro de cripto es un mercado distinto al de opciones sobre acciones, y nadie te va a regalar datos tick de un activo que elijas. Lo que sí obtienes alcanza para construir una superficie, medir un skew, probar una idea de prima de varianza y saber si la estrategia vale la pena antes de pagar por datos.
Un hábito para copiar, uses la fuente que uses. Empieza hoy una foto nocturna de las cadenas que te interesan, aunque nunca la mires. El historial de opciones es el único dataset que no puedes recomprar barato, y dentro de un año el cron job que configures esta noche será la única muestra que nadie más tiene.
La red de superficie de volatilidad entrenada con el set de Kaggle: dos redes neuronales aprenden la superficie de IV de SPY
Las griegas que viven en estas cadenas: vanna, volga, color, zomma y speed
Publico los modelos a medida que los construyo, los cambios que les hago y los resultados que no funcionaron. Más cortos que estos artículos, y son muchos más.
Sígueme en X