Lección 3.6.4 · 75 min
Backtesting de opciones: datos, fills y ajustes por eventos
Un backtest de opciones trabaja con cientos de contratos por día, cada uno con su horquilla, y casi todos los errores vienen de los datos o de los precios de ejecución, no de la estrategia. Esta lección limpia una cadena diaria con defectos plantados y su lista de respuestas, mide cuánto cambia un resultado al ejecutar al medio o al bid/ask, y trata los dividendos, los resultados y el ejercicio anticipado de una acción con opciones americanas.
- Módulo
- 3.6 · Estrategias sistemáticas de volatilidad y opciones
- Duración
- 75 min
Lecciones del nivel 3
3.1 · Futuros en profundidad
3.2 · Spreads de futuros
- 3.2.1 Spreads de calendario, butterflies y condors
- 3.2.2 Spreads intercommodity: crack, spark y crush
- 3.2.3 Spreads de tipos y de índices: 2s10s, NOB, NQ/ES y Europa frente a EE. UU.
- 3.2.4 Ratios de cobertura: por nominal, por DV01 y por volatilidad
- 3.2.5 Datos y backtesting de spreads: cotizados, sintéticos y por patas
- 3.2.6 Estacionalidad de spreads: análisis multi-año, patrones y trampas
3.3 · Forex avanzado
3.4 · Teoría y valoración de opciones
- 3.4.1 Payoffs, paridad put-call y límites de arbitraje
- 3.4.2 Árboles binomiales, Black-Scholes-Merton, Black-76 y Monte Carlo
- 3.4.3 Griegas: delta, gamma, vega, theta, rho, vanna, volga y charm
- 3.4.4 Volatilidad implícita: Newton-Raphson, Brent, py_vollib y QuantLib
- 3.4.5 Sonrisa, skew y estructura temporal; superficie de volatilidad con SVI
- 3.4.6 Volatilidad realizada frente a implícita; prima de riesgo de volatilidad; VIX y VSTOXX
- 3.4.7 Modelos avanzados: Heston, SABR y volatilidad local
3.5 · Estructuras de opciones en Python
- 3.5.1 Estructuras direccionales y verticales: call, put, covered call, collar y spreads
- 3.5.2 Estructuras de volatilidad y rango: straddles, strangles, cóndores y mariposas
- 3.5.3 Calendarios, diagonales, ratios, backspreads, risk reversals y jade lizard
- 3.5.4 Herramienta propia: payoff, griegas agregadas y escenarios
- 3.5.5 Gestión: ajustes, roll, cierre anticipado, asignación temprana y pin risk
3.6 · Estrategias sistemáticas de volatilidad y opciones
3.7 · Cripto derivados y estrategias específicas
Por qué los backtests de opciones fallan por los datos
Un backtest de acciones diario trabaja con un precio por activo y día. Uno de opciones, con una cadena: decenas de strikes por vencimiento, varios vencimientos, call y put, cada contrato con su bid, su ask, su volumen y su interés abierto. La cadena diaria del índice ORX de los datos del curso tiene unas 180 filas por sesión con solo tres vencimientos; la de un índice real, con vencimientos semanales y diarios, tiene muchas más. Con tantos precios, los errores de datos dejan de ser excepciones y pasan a ser una población: cotizaciones que no se actualizan, bids por encima del ask, días sin datos. Y con horquillas que en las opciones fuera del dinero llegan a ser una fracción grande del precio, el supuesto de ejecución pesa más que en cualquier otro activo.
Fuente Mermaid
flowchart TB D[Cadena cruda] --> L[Limpieza] L --> S[Selección en t] S --> E[Fill de entrada] E --> V[Valoración diaria] V --> X[Vence o se cierra] X --> M[Métricas y cola]
Cada caja tiene su trampa. La selección debe usar solo lo que se sabía al cierre de ese día (el strike de delta
0,16 se calcula con la implícita de ese cierre, no con la de mañana). El fill decide cuánto se cobra o se paga. La
valoración diaria al medio necesita una cotización válida cada día para cada pata. El vencimiento liquida por
el valor intrínseco o, en opciones americanas, puede llegar antes por ejercicio (lección 3.5.5). Esta lección recorre
las cajas con datos sintéticos que llevan defectos plantados y la lista de esos defectos
(opciones3_incidencias.csv): la respuesta conocida contra la que medir cada regla de limpieza.
Fuentes de datos reales
En EE. UU., todas las bolsas de opciones publican sus cotizaciones y operaciones a través de un único procesador, OPRA (Options Price Reporting Authority). Según su web, OPRA es un procesador de información de valores registrado cuyos miembros son las bolsas de opciones autorizadas por la SEC, y lo que distribuye son, sobre todo, operaciones («last sale») y cotizaciones, más volúmenes, interés abierto y resúmenes de fin de día. Todo lo demás se deriva de ese flujo:
| Producto | Qué trae | Granularidad | Uso típico |
|---|---|---|---|
Flujo OPRA (p. ej. Databento OPRA.PILLAR) | Mejor bid/ask consolidado, operaciones, estadísticas, definiciones | Cada mensaje, o agregado por segundo o minuto | Microestructura, ejecución, intradía |
| Resumen de fin de día (p. ej. Cboe Option EOD Summary) | Bid/ask con tamaño a las 15:45 ET y al cierre; OHLC, volumen e interés abierto al cierre | Dos fotos diarias, desde enero de 2012 | Backtests diarios |
| Cadenas históricas con implícitas y griegas (proveedores académicos y comerciales) | Lo anterior más implícitas, griegas y superficies calculadas | Diaria | Research, superficies |
Dos detalles de la tabla importan en un backtest diario. El primero: Cboe da una foto a las 15:45 y otra al cierre porque el cierre de las opciones y el del subyacente no son simultáneos, y una implícita calculada con precios de horas distintas está mal. La foto de las 15:45 trae, según su página, las implícitas y griegas opcionales calculadas a esa hora. El segundo: Databento indica que antes del 2023-03-28 su historia de OPRA procede de datos submuestreados, con el BBO por minuto como máximo detalle; para fechas anteriores no hay cotizaciones mensaje a mensaje.
El código para pedir a Databento las cotizaciones de un día de las opciones del SPX es este (necesita clave y red; no
se ejecuta aquí). La simbología parent con el sufijo .OPT agrupa todas las opciones de una raíz, según su
documentación:
import databento as db
client = db.Historical("CLAVE")
datos = client.timeseries.get_range(
dataset="OPRA.PILLAR",
schema="cbbo-1m", # mejor bid/ask consolidado por minuto
stype_in="parent",
symbols=["SPX.OPT"],
start="2024-03-15T19:00:00",
end="2024-03-15T20:00:00",
)
df = datos.to_df()Cada contrato llega con su símbolo OSI, el formato de la cámara (OCC): 21 caracteres con la raíz de seis caracteres
rellena con espacios, la fecha de vencimiento AAMMDD, C o P y el strike por 1.000 en ocho dígitos (el ejemplo de
Databento es SPY 231117P00486000). Construirlo y leerlo es lo primero que hace cualquier código que una datos de
dos fuentes:
import numpy as np
import pandas as pd
cad = pd.read_parquet("opciones3_cadena.parquet")
ind = pd.read_csv("opciones3_indice.csv", parse_dates=["fecha"])
ind = ind.set_index("fecha")
def osi(raiz, venc, tipo, strike):
return (f"{raiz:<6}{venc:%y%m%d}{tipo}"
f"{int(round(strike * 1000)):08d}")
def lee_osi(s):
return {"raiz": s[:6].strip(),
"vencimiento": pd.Timestamp("20" + s[6:12]),
"tipo": s[12], "strike": int(s[13:]) / 1000}
fila = cad.iloc[3]
s = osi("ORX", fila["vencimiento"], fila["tipo"], fila["strike"])
print(repr(s), len(s))
print(lee_osi(s))
print(lee_osi("SPY 231117P00486000"))'ORX 140117C01750000' 21
{'raiz': 'ORX', 'vencimiento': Timestamp('2014-01-17 00:00:00'), 'tipo': 'C', 'strike': 1750.0}
{'raiz': 'SPY', 'vencimiento': Timestamp('2023-11-17 00:00:00'), 'tipo': 'P', 'strike': 486.0}La cadena del curso y sus huecos
La cadena de ORX (opciones3_cadena.parquet) imita las opciones del SPX: europeas, liquidación en efectivo al cierre
del vencimiento (simplificación: en el S&P 500 real, las mensuales liquidan a la apertura), multiplicador de 100 USD,
tick de 0,05 por debajo de 3 puntos y de 0,10 por encima y los tres vencimientos mensuales más cercanos. Antes de
cualquier estrategia, cuatro preguntas: cuántos contratos hay, qué sesiones faltan, cuáles son anómalas y cómo son las
horquillas.
por_dia = cad.groupby("fecha").size()
print("filas por sesión:", por_dia.describe()[["min", "50%", "max"]]
.astype(int).to_dict())
faltan = ind.index.difference(cad["fecha"].unique())
print("sesiones del índice sin cadena:", [d.date() for d in faltan])
cortas = ind.index[ind["sesion_corta"]]
vol = cad.groupby("fecha")["volumen"].sum()
print(f"medias sesiones: {len(cortas)}; volumen medio frente a una "
f"sesión normal: {vol.reindex(cortas).mean() / vol.mean():.0%}")filas por sesión: {'min': 86, '50%': 174, 'max': 396}
sesiones del índice sin cadena: [datetime.date(2019, 7, 23)]
medias sesiones: 26; volumen medio frente a una sesión normal: 35%El 2019-07-23 el índice tiene cierre y la cadena no: el día sin cadena plantado. Un backtest que haga
cadena[cadena.fecha == d] ese día recibe una tabla vacía y, según cómo esté escrito, abre posiciones con precio
NaN, valora la cartera a cero o falla. La regla razonable es explícita: ese día no se abre nada y la valoración se
repite (o se interpola y se dice). Las 26 medias sesiones (cierre a las 13:00 en la NYSE) tienen un tercio del
volumen: no son un error, pero sus cotizaciones son menos representativas, y la theta de ese día es la de un día
natural completo.
Las horquillas, en % del medio, por distancia al dinero (opciones fuera del dinero, con bid positivo), y la fracción de bids nulos:
cad["medio"] = (cad["bid"] + cad["ask"]) / 2
cad["semi"] = (cad["ask"] - cad["bid"]) / 2
S = ind["cierre"]
cad["S"] = S.reindex(cad["fecha"]).to_numpy()
otm = np.where(cad["tipo"] == "P", cad["strike"] <= cad["S"],
cad["strike"] >= cad["S"])
dist = np.abs(np.log(cad["strike"] / cad["S"]))
tramos = pd.cut(dist, [0, 0.02, 0.05, 0.10, 0.15, 1.0],
labels=["<2 %", "2-5 %", "5-10 %", "10-15 %", ">15 %"])
ok = otm & (cad["bid"] > 0)
pct = 100 * 2 * cad["semi"] / cad["medio"]
print(f"bids nulos: {(cad['bid'] == 0).mean():.1%} de las filas")
pct[ok].groupby(tramos[ok], observed=True).median().round(1)bids nulos: 11.1% de las filas
<2 % 1.2
2-5 % 1.8
5-10 % 2.9
10-15 % 4.5
>15 % 6.7
dtype: float64En el dinero, la horquilla mediana es del 1,2 % del precio; entre el 5 y el 10 % fuera, del 2,9 %; a más del 15 %, del 6,7 %: las cifras de la respuesta conocida del README. La figura separa puts y calls: por el skew, las calls fuera del dinero valen muy poco, y una horquilla parecida en puntos es un porcentaje mucho mayor de su precio. Y el 11,1 % de las filas tienen bid nulo: opciones tan fuera del dinero que nadie paga nada por ellas. En esas filas el medio (la mitad del ask) es un precio que no existe: nadie compra a ese precio y nadie puede vender. Vender una opción con bid nulo en un backtest al medio es fabricar prima.
Limpieza: cruzadas, congeladas y paridad
Tres reglas, cada una contrastada con la lista de incidencias plantadas. La primera es la más fácil: un bid por encima del ask (cotización cruzada) no puede ser una cotización ejecutable.
inc = pd.read_csv("opciones3_incidencias.csv",
parse_dates=["fecha", "vencimiento"])
clave = ["fecha", "vencimiento", "tipo", "strike"]
print(inc["incidencia"].value_counts().to_dict())
cruz = cad[cad["bid"] > cad["ask"]]
cruz.merge(inc, on=clave, how="left")["incidencia"].value_counts(){'cotizacion_congelada': 1500, 'cotizacion_cruzada': 12, 'paridad_violada': 8, 'dia_sin_cadena': 1}
incidencia
cotizacion_cruzada 12
Name: count, dtype: int64Las 12 cruzadas que hay en la cadena son exactamente las 12 plantadas. Las congeladas son más difíciles: la cotización de ayer repetida hoy. El generador las plantó en opciones lejanas (delta absoluta por debajo de 0,12) los días en que el índice se movió más de un 1 %. La regla obvia, «igual que ayer en un día de movimiento», y una versión que excluye los bids nulos:
cad = cad.sort_values(clave[1:] + ["fecha"]).reset_index(drop=True)
g = cad.groupby(["vencimiento", "tipo", "strike"])
ses = pd.Series(sorted(cad["fecha"].unique()))
anterior = dict(zip(ses.iloc[1:], ses.iloc[:-1]))
cad["igual"] = ((cad["bid"] == g["bid"].shift())
& (cad["ask"] == g["ask"].shift())
& (g["fecha"].shift() == cad["fecha"].map(anterior)))
rend = np.log(S).diff()
cad["mueve"] = rend.abs().reindex(cad["fecha"]).to_numpy() > 0.01
verdad = inc[inc["incidencia"] == "cotizacion_congelada"]
def mide(marcadas):
m = marcadas.merge(verdad, on=clave, how="left")
acierto = m["incidencia"].notna().sum()
return {"marcadas": len(marcadas), "aciertos": acierto,
"precision": acierto / len(marcadas),
"recall": acierto / len(verdad)}
reglas = {"igual y |r| > 1 %": cad["igual"] & cad["mueve"],
"... y bid > 0": cad["igual"] & cad["mueve"]
& (cad["bid"] > 0)}
pd.DataFrame({k: mide(cad[v]) for k, v in reglas.items()}).T.round(3) marcadas aciertos precision recall
igual y |r| > 1 % 11336.0 1500.0 0.132 1.000
... y bid > 0 1940.0 1090.0 0.562 0.727La primera regla encuentra todas (recall del 100 %), pero solo una de cada ocho marcadas es una congelada plantada: el
resto son opciones con bid 0 y ask en el tick mínimo, cuya cotización no cambia porque no vale casi nada. Exigir un
bid positivo sube la precisión al 56 % y deja escapar el 27 % de las congeladas: las de menos valor. ¿Importan las que
se escapan? Para saberlo hay que medir el error de precio de cada una frente al valor del modelo (con iv_modelo, la
implícita verdadera; en datos reales no existe y habría que compararlo con la sonrisa de los strikes vecinos):
from scipy.special import ndtr
def bs_precio(tipo, S, K, T, iv, r, q):
F = S * np.exp((r - q) * T)
sd = iv * np.sqrt(T)
d1 = (np.log(F / K) + 0.5 * sd * sd) / sd
df = np.exp(-r * T)
call = df * (F * ndtr(d1) - K * ndtr(d1 - sd))
return np.where(tipo == "C", call, call - df * (F - K))
cg = cad.merge(verdad, on=clave, how="left")
cg = cg[cg["igual"] & cg["mueve"]].copy()
f_ = cg["fecha"]
cg["modelo"] = bs_precio(
cg["tipo"].to_numpy(), cg["S"].to_numpy(),
cg["strike"].to_numpy(),
((cg["vencimiento"] - f_).dt.days / 365).to_numpy(),
cg["iv_modelo"].to_numpy(),
ind["tipo_1m"].reindex(f_).to_numpy() / 100,
ind["rentabilidad_dividendo"].reindex(f_).to_numpy() / 100)
cg["error_semis"] = (cg["medio"] - cg["modelo"]).abs() / cg["semi"]
cg["grupo"] = np.select(
[cg["incidencia"].isna(), cg["bid"] > 0],
["no plantada", "plantada, bid > 0"], "plantada, bid 0")
cg.groupby("grupo")["error_semis"].describe(
percentiles=[0.5, 0.9])[["count", "50%", "90%", "max"]].round(2) count 50% 90% max
grupo
no plantada 9836.0 0.97 1.00 1.00
plantada, bid 0 410.0 0.99 1.00 33.52
plantada, bid > 0 1090.0 10.74 34.33 219.42El error se mide en semihorquillas: 1 quiere decir que el medio está a una semihorquilla del valor, el límite a partir del cual se podría comprar o vender con beneficio. Las congeladas plantadas con bid positivo tienen un error mediano de 10,7 semihorquillas, 34 en el percentil 90: un backtest que las use para entrar o para valorar «gana» o «pierde» un precio que no existía. Las no plantadas no pasan de una semihorquilla (el medio de una cotización de 0 y 0,05 está a media horquilla de un valor casi nulo), y las plantadas con bid 0 tampoco, salvo alguna: no hace falta limpiarlas para no equivocarse. La regla útil no es «quitar todo lo que no cambia», sino «no ejecutar contra una cotización que no ha cambiado cuando el modelo dice que debería haberlo hecho».
La tercera regla es la paridad put-call (lección 3.4.1): para una call y una put europeas del mismo strike y vencimiento, . En unos datos reales no se conoce el forward, pero la propia cadena lo da: cada par implica , y la mediana de los de los siete strikes más cercanos al dinero es una estimación robusta. Con ella, se marcan los pares que se salen de la paridad por más de la suma de sus semihorquillas:
pc = cad.pivot_table(index=["fecha", "vencimiento", "strike"],
columns="tipo", values=["bid", "ask", "medio"])
pc.columns = [f"{a}_{b}" for a, b in pc.columns]
pc = pc.dropna().reset_index()
pc["T"] = (pc["vencimiento"] - pc["fecha"]).dt.days / 365
curva = ind[["tipo_1m", "tipo_3m", "tipo_6m", "tipo_1a"]] / 100
plazos = [1 / 12, 0.25, 0.5, 1.0]
pc["r"] = [np.interp(t, plazos, fila) for t, fila in
zip(pc["T"], curva.reindex(pc["fecha"]).to_numpy())]
pc["F_K"] = pc["strike"] + np.exp(pc["r"] * pc["T"]) * (
pc["medio_C"] - pc["medio_P"])
pc["dist"] = (pc["strike"] - S.reindex(pc["fecha"]).to_numpy()).abs()
cerca = pc.groupby(["fecha", "vencimiento"])["dist"].rank(
method="first") <= 7
F_est = (pc[cerca].groupby(["fecha", "vencimiento"])["F_K"].median()
.rename("F_est"))
pc = pc.merge(F_est.reset_index(), on=["fecha", "vencimiento"])
err = (pc["medio_C"] - pc["medio_P"]
- np.exp(-pc["r"] * pc["T"]) * (pc["F_est"] - pc["strike"]))
lim = (pc["ask_C"] - pc["bid_C"] + pc["ask_P"] - pc["bid_P"]) / 2
fuera = pc[err.abs() > lim]
ver = pd.read_parquet("opciones3_verdad.parquet")
cmp_ = F_est.reset_index().merge(ver, on=["fecha", "vencimiento"])
print(f"forward estimado frente al verdadero: error mediano "
f"{(cmp_.F_est - cmp_.forward).abs().median():.3f} puntos")
print(f"pares fuera de la paridad: {len(fuera)} de {len(pc)}")
inc1 = inc.drop_duplicates(["fecha", "vencimiento", "strike"])
fuera.merge(inc1, on=["fecha", "vencimiento", "strike"],
how="left")["incidencia"].value_counts(dropna=False)forward estimado frente al verdadero: error mediano 0.021 puntos
pares fuera de la paridad: 346 de 272274
incidencia
cotizacion_congelada 330
paridad_violada 8
cotizacion_cruzada 8
Name: count, dtype: int64El forward estimado se equivoca en 0,02 puntos de mediana con un índice de miles: la paridad de los strikes cercanos lo fija con mucha precisión. Quedan fuera 346 pares de 272.274, y todos tienen una incidencia plantada: 330 por una pata congelada, las 8 paridades violadas y 8 de las cruzadas. Es la respuesta conocida del README (345 pares con el forward verdadero, todos con incidencia). En la práctica es la mejor regla de las tres, porque no necesita modelo: una violación de la paridad mayor que las horquillas en opciones europeas líquidas es, casi siempre, un dato malo y no un arbitraje.
Fuente Mermaid
flowchart TB
Q[Cotización] --> A{bid mayor que ask}
A -- sí --> X1[Descartar]
A -- no --> B{bid nulo}
B -- sí --> X2[No vender]
B -- no --> C{Paridad rota}
C -- sí --> X3[Sospechosa]
C -- no --> D{Igual que ayer}
D -- sí --> X4[No ejecutar]
D -- no --> OK[Usar]El motor: selección, fills y valoración
El motor de la celda siguiente vende (o compra) una estructura cada mes: el día de un vencimiento mensual abre las
patas en el vencimiento siguiente con al menos 20 días, elige cada strike con la información de ese cierre (delta de
Black-Scholes con la implícita de ese día o el strike ATM), valora cada día al medio, repite la valoración el día sin
cadena y liquida al vencimiento por el valor intrínseco. La caja rinde el tipo a 1 mes. El parámetro frac es el
modelo de fills: la fracción de la semihorquilla que se paga al entrar y al salir. frac = 1 vende al bid y compra
al ask; frac = 0, todo al medio; frac = 0,5, a medio camino, un supuesto habitual para órdenes limitadas pacientes.
cierre permite deshacer la posición antes del vencimiento cuando su valor cae por debajo de una fracción del
crédito cobrado (lección 3.5.5), con un segundo cruce de horquillas.
r1m = ind["tipo_1m"] / 100
q = ind["rentabilidad_dividendo"] / 100
por_fecha = {f: d.set_index(["vencimiento", "tipo", "strike"])
for f, d in cad.groupby("fecha")}
vencs = sorted(cad["vencimiento"].unique())
def delta_bs(tipo, S0, K, T, iv, r0, q0):
F = S0 * np.exp((r0 - q0) * T)
sd = iv * np.sqrt(T)
d1 = (np.log(F / K) + 0.5 * sd * sd) / sd
return np.exp(-q0 * T) * (ndtr(d1) - (tipo == "P"))
def elige(sub, t, obj, f, T):
st = sub.loc[t]
if obj == "atm": # put: el strike más alto bajo el índice
ks = st.index[st.index <= S[f]]
return float(ks.max())
dl = delta_bs(t, S[f], st.index.to_numpy(), T,
st["iv_modelo"].to_numpy(), r1m[f], q[f])
return float(st.index[np.argmin(np.abs(np.abs(dl) - obj))])
def backtest(patas, frac=1.0, garantia="indice", cierre=None):
"""patas: (tipo, delta objetivo o "atm", signo: -1 vende)."""
caja, pos, venc, eq, ultimo = 1.0, [], None, {}, 1.0
credito, coste = 0.0, 0.0
for f in S.index:
g = por_fecha.get(f) # None el día sin cadena
if venc is not None and f == venc: # liquidación
caja += sum(n * sg * max(k - S[f] if t == "P"
else S[f] - k, 0.0)
for t, k, sg, n in pos)
pos, venc = [], None
if venc is None and g is not None: # abre la siguiente
v = next(x for x in vencs if (x - f).days >= 20)
T = (v - f).days / 365
sel = [(t, elige(g.loc[v], t, obj, f, T), sg)
for t, obj, sg in patas]
n = caja / (sel[0][1] if garantia == "strike" else S[f])
for t, k, sg in sel:
fila = g.loc[(v, t, k)]
caja -= sg * n * (fila["medio"] + sg * frac
* fila["semi"])
coste += n * frac * fila["semi"]
credito = -sum(sg * g.loc[(v, t, k)]["medio"]
for t, k, sg in sel)
pos, venc = [(t, k, sg, n) for t, k, sg in sel], v
marca, valida = 0.0, g is not None
if pos and valida:
filas = [g.loc[(venc, t, k)] for t, k, sg, n in pos]
marca = sum(sg * n * fl["medio"]
for (t, k, sg, n), fl in zip(pos, filas))
valor = -sum(sg * fl["medio"]
for (t, k, sg, n), fl in zip(pos, filas))
if cierre is not None and valor <= cierre * credito:
for (t, k, sg, n), fl in zip(pos, filas):
caja += sg * n * (fl["medio"] - sg * frac
* fl["semi"])
coste += n * frac * fl["semi"]
pos, marca = [], 0.0
eq[f] = ultimo if (pos and not valida) else caja + marca
ultimo = eq[f]
caja *= np.exp(r1m[f] / 252)
return pd.Series(eq), coste
def metricas(eq):
rr = eq.pct_change().dropna()
ex = rr - r1m.reindex(rr.index) / 252
mes = eq.resample("ME").last().pct_change().dropna()
anos = (eq.index[-1] - eq.index[0]).days / 365.25
return {"sharpe": ex.mean() / ex.std() * np.sqrt(252),
"anual": (eq.iloc[-1] / eq.iloc[0]) ** (1 / anos) - 1,
"caida_max": (eq / eq.cummax() - 1).min(),
"peor_mes": mes.min()}La primera prueba es la respuesta conocida del README: el put-write ATM con garantía en efectivo (nominal = strike, como el índice PUT de Cboe) da un Sharpe de 0,52 y un 6,8 % anual al bid, y 0,53 y 6,9 % al medio.
put = [("P", "atm", -1)]
res = {}
for frac in (1.0, 0.0):
eq, cst = backtest(put, frac, garantia="strike")
res[f"put-write, frac {frac:g}"] = metricas(eq) | {"horquilla": cst}
pd.DataFrame(res).T.round(3) sharpe anual caida_max peor_mes horquilla
put-write, frac 1 0.520 0.068 -0.395 -0.232 0.02
put-write, frac 0 0.533 0.069 -0.394 -0.232 0.00Coincide con el README. En una put ATM a un mes, la horquilla es pequeña frente a la prima, y vender al bid en lugar de al medio cuesta una décima de punto de rentabilidad al año. Aquí el supuesto de ejecución apenas importa. En una estructura de cuatro patas fuera del dinero, sí: un iron condor vende la put y la call de delta 0,16 y compra la put y la call de delta 0,05 como alas (lección 3.5.2), con nominal = índice; y su versión con cierre anticipado lo deshace cuando ha ganado la mitad del crédito.
condor = [("P", 0.16, -1), ("C", 0.16, -1),
("P", 0.05, 1), ("C", 0.05, 1)]
for frac in (1.0, 0.5, 0.0):
eq, cst = backtest(condor, frac)
res[f"condor, frac {frac:g}"] = metricas(eq) | {"horquilla": cst}
for frac in (1.0, 0.0):
eq, cst = backtest(condor, frac, cierre=0.5)
res[f"condor 50 %, frac {frac:g}"] = (metricas(eq)
| {"horquilla": cst})
tabla = pd.DataFrame(res).T
tabla.round(3) sharpe anual caida_max peor_mes horquilla
put-write, frac 1 0.520 0.068 -0.395 -0.232 0.020
put-write, frac 0 0.533 0.069 -0.394 -0.232 0.000
condor, frac 1 0.053 0.020 -0.173 -0.055 0.026
condor, frac 0.5 0.079 0.021 -0.171 -0.055 0.013
condor, frac 0 0.104 0.022 -0.170 -0.055 0.000
condor 50 %, frac 1 0.266 0.026 -0.091 -0.055 0.039
condor 50 %, frac 0 0.364 0.029 -0.088 -0.055 0.000La columna horquilla suma lo pagado en semihorquillas por unidad de nominal en toda la muestra. En el condor
mantenido al vencimiento, ejecutar al bid/ask en lugar de al medio baja el Sharpe de 0,10 a 0,05; con cierre
anticipado, que cruza las horquillas dos veces, de 0,36 a 0,27. La rentabilidad anual cambia poco en puntos (unas
décimas), pero la rentabilidad en exceso del condor es pequeña, y la horquilla se lleva una parte grande de ella. Un
backtest de una estructura así ejecutado al medio no dice casi nada; uno al bid/ask es pesimista para quien trabaja
con órdenes limitadas; la columna frac 0,5 es el supuesto honesto si no hay datos de ejecuciones propias.
Eventos: dividendos, resultados y ejercicio anticipado
Las opciones sobre un índice europeo tienen pocos eventos: los dividendos del índice están en el forward (por eso la
lección usa y no para la moneyness), y nada se ejerce antes de tiempo. Las opciones sobre acciones tienen
tres. La acción ficticia KRN de los datos (accion3_diario.csv, accion3_eventos.csv, accion3_opciones.parquet)
imita a una gran industrial de EE. UU. con dividendo trimestral, resultados cuatro veces al año y opciones americanas.
El primero es el precio de referencia. Los proveedores de series diarias suelen dar un cierre ajustado por dividendos (y por splits): reescriben el pasado para que los rendimientos sean continuos. Los contratos de opciones, en cambio, se listaron con los precios de entonces. Para la moneyness, la selección de strikes y el valor intrínseco, el precio es siempre el sin ajustar:
krn = pd.read_csv("accion3_diario.csv", parse_dates=["fecha"])
krn = krn.set_index("fecha")
div = krn["dividendo_ex"]
# ajuste hacia atrás, el de los proveedores: factor 1 - D / cierre
# del día anterior a la fecha ex, acumulado hacia el pasado
factor = (1 - div / krn["cierre"].shift()).fillna(1)
acum = factor[::-1].cumprod()[::-1].shift(-1).fillna(1)
ajustado = krn["cierre"] * acum
d0 = krn.index[0]
print(f"{d0.date()}: sin ajustar {krn['cierre'][d0]:.2f}, "
f"ajustado {ajustado[d0]:.2f} "
f"({ajustado[d0] / krn['cierre'][d0] - 1:.1%})")
print(f"escalones de 2,5 USD de diferencia: "
f"{(krn['cierre'][d0] - ajustado[d0]) / 2.5:.1f}")2022-01-03: sin ajustar 117.52, ajustado 105.62 (-10.1%)
escalones de 2,5 USD de diferencia: 4.8Con los 16 dividendos posteriores, el cierre ajustado del 3 de enero de 2022 queda un 10,1 % por debajo del real (105,62 frente a 117,52 USD): casi cinco escalones de strike. Un backtest que elija el strike «ATM» con la serie ajustada compra opciones dentro o fuera del dinero sin saberlo, y calcula mal su valor intrínseco al vencimiento. Los splits son peores (el ajuste cambia el precio a la mitad o a un tercio), y en ellos la cámara ajusta también los contratos: cambian el número de acciones que entrega cada contrato o el strike, y el contrato ajustado pasa a tener otro símbolo. Los dividendos ordinarios, en cambio, no ajustan los contratos: los descuenta su precio1Nota 1 · Lo que hace la cámara se resume en la notificación de la SEC sobre la propuesta de la OCC SR-OCC-2006-01 (Federal Register 71 FR 67415, 21 de noviembre de 2006): «as a general rule, outstanding options will not be adjusted to compensate for ordinary cash dividends», mientras que los dividendos extraordinarios, los splits y las escisiones sí se ajustan: https://www.govinfo.gov/content/pkg/FR-2006-11-21/html/E6-19619.htm. Las reglas concretas vigentes están en los estatutos de la OCC (artículo VI, sección 11A)..
El segundo evento son los resultados. Antes de un anuncio, la implícita del vencimiento más cercano incluye la varianza del salto esperado; al día siguiente, el salto ya ha pasado y la implícita cae. En KRN:
ev = pd.read_csv("accion3_eventos.csv", parse_dates=["fecha"])
ops = pd.read_parquet("accion3_opciones.parquet")
res_ = ev[ev["evento"] == "resultados"]
dias = krn.index
def iv_atm(d):
"""Call ATM del primer vencimiento con el cierre del día."""
sub = ops[(ops["fecha"] == d) & (ops["tipo"] == "C")]
sub = sub[sub["vencimiento"] == sub["vencimiento"].min()]
fila = sub.loc[(sub["strike"] - krn["cierre"][d]).abs().idxmin()]
return 100 * fila["iv_modelo"]
filas = []
for _, e in res_.iterrows():
j = dias.get_loc(e["fecha"])
if j + 1 >= len(dias):
continue # anuncio en la última sesión
filas.append((e["fecha"].date(), iv_atm(dias[j]),
iv_atm(dias[j + 1]), 100 * e["sigma_evento"],
100 * e["movimiento_real"]))
crush = pd.DataFrame(filas, columns=["anuncio", "iv_antes",
"iv_despues", "sigma_evento",
"movimiento"])
print(crush[["iv_antes", "iv_despues", "sigma_evento"]].mean()
.round(1).to_dict())
print(f"|movimiento| > sigma del evento: "
f"{(crush.movimiento.abs() > crush.sigma_evento).mean():.0%}")
crush.round(1).head(4){'iv_antes': 33.2, 'iv_despues': 24.6, 'sigma_evento': 5.7}
|movimiento| > sigma del evento: 31%
anuncio iv_antes iv_despues sigma_evento movimiento
0 2022-01-25 21.5 16.9 3.6 -3.3
1 2022-04-26 28.8 14.8 6.4 -4.2
2 2022-07-26 34.4 20.2 7.1 10.0
3 2022-10-25 23.8 15.3 4.9 4.1La implícita ATM cae de media del 33,2 % al 24,6 % del cierre del anuncio al siguiente, con una desviación típica plantada del movimiento del 5,7 %. Es la respuesta conocida del README. La lección para un backtest no es que venderla gane: es que la implícita alrededor de un evento conocido no se puede comparar con la de otros días. Una regla del tipo «vender cuando la implícita está en su percentil 90» venderá sistemáticamente antes de los resultados, y su resultado dependerá de los días del salto, que aquí superan su desviación típica plantada en el 31 % de los anuncios. Los resultados se evalúan evento a evento, con su peor caso.
El tercero es el ejercicio anticipado. Una call americana sobre una acción con dividendo puede valer más viva
que ejercida, salvo la víspera de la fecha ex, cuando ejercerla captura el dividendo; una put americana muy dentro del
dinero se ejerce antes con tipos altos, porque cobrar el strike hoy rinde intereses (lección 3.4.2). En los datos,
precio_modelo es la americana (árbol binomial con dividendos discretos) y europea_modelo la misma opción europea:
ops["prima_ej"] = ops["precio_modelo"] - ops["europea_modelo"]
print(f"americana > europea + 0,01: "
f"{(ops['prima_ej'] > 0.01).mean():.0%} de las filas; "
f"máximo {ops['prima_ej'].max():.2f} USD")
ex_div = div[div > 0].index
ops["vispera_ex"] = ops["fecha"].isin(
[dias[dias.get_loc(d) - 1] for d in ex_div])
s_ = krn["cierre"].reindex(ops["fecha"]).to_numpy()
ops["intrinseco"] = np.where(ops["tipo"] == "C",
np.maximum(s_ - ops["strike"], 0),
np.maximum(ops["strike"] - s_, 0))
# ejercer es óptimo si la americana no vale más que su intrínseco
ops["ejercer"] = ops["precio_modelo"] - ops["intrinseco"] < 0.005
itm = ops[ops["intrinseco"] > 0]
itm.groupby(["tipo", "vispera_ex"])["ejercer"].agg(
["count", "mean"]).round(3)americana > europea + 0,01: 50% de las filas; máximo 2.35 USD
count mean
tipo vispera_ex
C False 31423 0.005
True 494 0.071
P False 34301 0.215
True 530 0.000La prima por ejercicio anticipado existe en la mitad de las filas y llega a 2,35 USD por acción. La tabla mide otra cosa: en qué fracción de las opciones dentro del dinero ejercer ya es lo óptimo (la americana vale su intrínseco). En las calls, un 0,5 % de los días normales y un 7,1 % la víspera de una fecha ex, catorce veces más: es cuando su titular la ejerce para cobrar el dividendo, y quien la vendió recibe la asignación y debe entregar las acciones (y el dividendo). En las puts es al revés: ejercer es óptimo en el 21,5 % de los días normales, por los tipos del 4-5 % (cobrar hoy el strike rinde intereses), y nunca la víspera de una fecha ex, porque la caída del precio por el dividendo al día siguiente vale más que esperar un día. Un backtest que trate estas opciones como europeas y las lleve hasta el vencimiento sobrevalora las ventas de calls cubiertas antes de los dividendos y pierde el riesgo de asignación, que cambia la posición de un día para otro.
Lista de comprobación de un backtest de opciones
- Datos. Foto a la misma hora para opciones y subyacente (15:45 o cierre, no mezcladas). Strikes por precio sin ajustar. Días sin cadena y medias sesiones identificados.
- Limpieza. Cruzadas fuera; bids nulos no se venden; paridad con el forward de la propia cadena; cotizaciones sin cambio en días de movimiento, sin ejecutar contra ellas.
- Selección. Strikes y deltas con información del cierre de la decisión; implícitas calculadas desde los precios de ese día, no una columna del futuro.
- Ejecución. Al menos dos supuestos de fill (medio y bid/ask) y, si se puede, uno intermedio; comisiones por contrato; el cierre anticipado vuelve a cruzar la horquilla.
- Eventos. Dividendos y resultados en el calendario; ejercicio anticipado y asignación en opciones americanas; ajustes de contratos en splits y operaciones societarias.
- Resultados. Peor mes, caída máxima y su fecha junto al Sharpe; una estrategia de venta de prima sin su peor episodio no está evaluada (lección 3.6.1).
Ejercicios
- Mejora la regla de las congeladas: además de «igual que ayer en un día de movimiento», exige que el modelo diga que
la opción debería haberse movido más de una semihorquilla (usa
error_semiscomo sustituto). ¿Qué precisión y qué recall obtienes?
Solución
marc = cg[cg["error_semis"] > 1]
acierto = marc["incidencia"].notna().sum()
print(f"marcadas {len(marc)}, aciertos {acierto}, "
f"precisión {acierto / len(marc):.1%}, "
f"recall {acierto / len(verdad):.1%}")marcadas 1049, aciertos 1037, precisión 98.9%, recall 69.1%La precisión sube mucho: casi todo lo que la regla marca es una congelada plantada. El recall baja, pero lo que se escapa son congeladas cuyo precio está a menos de una semihorquilla del valor: ejecutarse contra ellas no da ni quita nada que la horquilla no cubra. En datos reales, el valor del modelo se sustituye por la implícita interpolada de los strikes vecinos en la sonrisa de ese día (lección 3.4.5).
- Calcula, para el iron condor al bid/ask, qué parte de la rentabilidad en exceso del colateral se lleva la horquilla frente al mismo condor al medio.
Solución
caja_media = np.exp(r1m.mean()) - 1 # la caja crece e^(r/252) al día
exc_bid = tabla.loc["condor, frac 1", "anual"] - caja_media
exc_med = tabla.loc["condor, frac 0", "anual"] - caja_media
print(f"tipo a 1 mes medio compuesto: {caja_media:.2%}")
print(f"exceso al medio {exc_med:.2%}, al bid/ask {exc_bid:.2%}: "
f"la horquilla se lleva el {1 - exc_bid / exc_med:.0%}")tipo a 1 mes medio compuesto: 1.82%
exceso al medio 0.35%, al bid/ask 0.13%: la horquilla se lleva el 62%Sobre la rentabilidad total, la diferencia entre fills parece pequeña; sobre la rentabilidad en exceso del colateral, que es lo que gana la estrategia, la horquilla se lleva una parte grande. Es la forma correcta de presentar el coste de ejecución de una estrategia de opciones: frente a lo que gana, no frente al capital.
- Un backtest de venta de straddles semanales sobre un índice real, con datos de fin de día, da un Sharpe de 3. Enumera las comprobaciones de esta lección que harías antes de creerlo.
Solución
Si los fills son al medio o al último precio (repetir con bid/ask y con frac 0,5; en opciones semanales la horquilla pesa mucho más frente a la prima). Si la foto de las opciones y la del subyacente son de la misma hora (una implícita calculada con un cierre de opciones a las 16:15 y uno del índice a las 16:00 está sesgada). Si hay cotizaciones congeladas o cruzadas en las patas vendidas y si la estrategia vende opciones con bid nulo. Si la selección de strikes usa datos del día siguiente (implícitas recalculadas a posteriori). Qué hace el día sin datos y el de media sesión. Cómo liquida: a la apertura o al cierre, con qué precio de referencia. Y su peor semana: la venta de straddles semanales tiene su cola en los saltos de una sesión, y un Sharpe de 3 sobre doce años sin una semana catastrófica es más probablemente un error de datos que una ventaja.
Footnotes
-
Lo que hace la cámara se resume en la notificación de la SEC sobre la propuesta de la OCC SR-OCC-2006-01 (Federal Register 71 FR 67415, 21 de noviembre de 2006): «as a general rule, outstanding options will not be adjusted to compensate for ordinary cash dividends», mientras que los dividendos extraordinarios, los splits y las escisiones sí se ajustan: https://www.govinfo.gov/content/pkg/FR-2006-11-21/html/E6-19619.htm. Las reglas concretas vigentes están en los estatutos de la OCC (artículo VI, sección 11A). ↩
Respuesta plegada en cada pregunta
Autoevaluación
Una regla marca como congelada toda cotización igual a la del día anterior en un día en que el índice se movió más de un 1 %. Encuentra las 1.500 plantadas y otras 9.836. ¿De dónde salen las demás?
- ADe errores del generador de datos
- BSobre todo de opciones muy fuera del dinero con bid 0 y ask en el tick mínimo: su cotización no cambia porque no vale casi nada, no porque esté congelada
- CDe la sesión del 2019-07-23
- DDe las medias sesiones
Ver respuesta
Respuesta B. Sobre todo de opciones muy fuera del dinero con bid 0 y ask en el tick mínimo: su cotización no cambia porque no vale casi nada, no porque esté congelada
Una opción con bid 0 y ask de 0,05 puede seguir así aunque el índice se mueva un 1 %. Exigir bid mayor que cero sube la precisión del 13 % al 56 %, a cambio de perder un 27 % de las congeladas reales. Las congeladas que se escapan son las de menos valor.
Con el forward estimado desde la propia cadena, 346 pares put-call quedan fuera de la paridad por más de su semihorquilla. ¿Qué son?
- AOportunidades de arbitraje
- BDefectos de los datos: los 346 coinciden con una incidencia plantada (330 congeladas, 8 paridades violadas y 8 cruzadas)
- CErrores del cálculo del forward
- DEl efecto del dividendo
Ver respuesta
Respuesta B. Defectos de los datos: los 346 coinciden con una incidencia plantada (330 congeladas, 8 paridades violadas y 8 cruzadas)
En unos datos limpios, la paridad se cumple dentro de las horquillas; lo que queda fuera es casi siempre una cotización mala. Un backtest que «compre la conversión» en esos pares estaría operando contra un dato roto.
Un put-write ATM mensual tiene un Sharpe de 0,53 al medio y de 0,52 al bid. Un iron condor de 16 y 5 delta, de 0,10 y 0,05. ¿Por qué tanta diferencia?
- APorque el condor tiene más riesgo de cola
- BPorque el condor cruza cuatro horquillas, tres de ellas en opciones fuera del dinero, donde la horquilla es varias veces mayor en proporción a la prima, y cobra una prima neta pequeña
- CPorque el put-write usa garantía en efectivo
- DPor azar
Ver respuesta
Respuesta B. Porque el condor cruza cuatro horquillas, tres de ellas en opciones fuera del dinero, donde la horquilla es varias veces mayor en proporción a la prima, y cobra una prima neta pequeña
El coste de ejecución se mide frente a la prima neta, no frente al nominal. La put ATM tiene una horquilla del 1,2 % de su precio; las patas del condor, de varias veces eso, y la prima neta es pequeña.
Para elegir el strike ATM de una opción sobre acciones de 2022 se usa el cierre ajustado por dividendos de un proveedor descargado en 2025. ¿Qué falla?
- ANada, el ajuste no afecta a las opciones
- BEl cierre ajustado de 2022 está por debajo del precio real de ese día (en KRN, un 10,1 % el 3 de enero de 2022); los strikes se refieren al precio sin ajustar, así que el strike «ATM» queda varios escalones dentro o fuera del dinero
- CQue los dividendos se pagan en otra fecha
- DQue las opciones americanas no tienen strike
Ver respuesta
Respuesta B. El cierre ajustado de 2022 está por debajo del precio real de ese día (en KRN, un 10,1 % el 3 de enero de 2022); los strikes se refieren al precio sin ajustar, así que el strike «ATM» queda varios escalones dentro o fuera del dinero
El ajuste por dividendos reescribe el pasado para que los rendimientos sean continuos; los contratos de opciones se listaron con los precios de entonces. Para la moneyness y el valor intrínseco se usa siempre el precio sin ajustar.
En la acción KRN, la implícita ATM del primer vencimiento cae de media del 33,2 % al 24,6 % del día del anuncio de resultados al siguiente. ¿Qué implica para un backtest?
- AQue conviene comprar opciones antes de los resultados
- BQue la implícita alrededor de un evento conocido no es comparable con la de otros días: un backtest que venda volatilidad «cara» antes de los resultados tiene que contar con el movimiento del día siguiente, que en KRN tiene una desviación típica plantada media del 5,7 %
- CQue los datos están mal
- DQue la vega es cero tras el anuncio
Ver respuesta
Respuesta B. Que la implícita alrededor de un evento conocido no es comparable con la de otros días: un backtest que venda volatilidad «cara» antes de los resultados tiene que contar con el movimiento del día siguiente, que en KRN tiene una desviación típica plantada media del 5,7 %
La implícita antes del anuncio incluye la varianza del salto esperado. Venderla puede ganar de media la caída de la implícita, pero pierde los días en que el movimiento supera lo descontado: hay que medirlo evento a evento, con la cola.
Ideas clave
- OPRA es el procesador que consolida cotizaciones y operaciones de todas las bolsas de opciones de EE. UU.; los resúmenes de fin de día y las cadenas históricas se derivan de él. Los contratos se identifican con símbolos OSI de 21 caracteres.
- Limpiar una cadena es medir: en los datos del curso, las 12 cotizaciones cruzadas se detectan todas; las congeladas, con una precisión que depende de excluir las opciones sin valor; y la paridad con un forward estimado de la propia cadena señala 346 pares, todos defectuosos.
- El fill decide el resultado cuando la prima neta es pequeña: al bid/ask, el Sharpe del iron condor cae de 0,10 a 0,05 y el del condor con cierre anticipado, de 0,36 a 0,27, mientras que el put-write apenas cambia.
- Días sin cadena, medias sesiones, bids nulos y strikes que aparecen y desaparecen exigen reglas explícitas: repetir la valoración, no operar, no vender a cero.
- En acciones, los strikes se comparan con precios sin ajustar; los resultados inflan la implícita antes del anuncio y los dividendos adelantan el ejercicio de calls americanas.