Lesson 3.6.4 · 75 min
Options backtesting: data, fills and event adjustments
An options backtest works with hundreds of contracts per day, each with its own bid-ask spread, and almost all errors come from the data or the execution prices, not from the strategy. This lesson cleans a daily chain with planted defects and its answer key, measures how much a result changes when executing at the mid or at the bid/ask, and handles the dividends, earnings and early exercise of a stock with American options.
- Module
- 3.6 · Systematic volatility and options strategies
- Duration
- 75 min
- Prerequisites
- 3.4.1 · Payoffs, put-call parity and arbitrage bounds, 3.5.5 · Management: adjustments, rolls, early closes, early assignment and pin risk, 3.6.1 · Systematic premium selling: put-write, strangles and their tail risk
- Author
- Jesús Cuesta
- Updated
Level 3 lessons
3.1 · Futures in depth
3.2 · Futures spreads
- 3.2.1 Calendar spreads, butterflies and condors
- 3.2.2 Intercommodity spreads: crack, spark and crush
- 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
- 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
- 3.2.5 Spread data and backtesting: listed, synthetic and legged
- 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
3.3 · Advanced forex
3.4 · Option theory and pricing
- 3.4.1 Payoffs, put-call parity and arbitrage bounds
- 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
- 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
- 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
- 3.4.5 Smile, skew and term structure; volatility surface with SVI
- 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
- 3.4.7 Advanced models: Heston, SABR and local volatility
3.5 · Option structures in Python
- 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
- 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
- 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
- 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
- 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
3.6 · Systematic volatility and options strategies
3.7 · Crypto derivatives and specific strategies
- 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
- 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
- 3.7.3 Crypto options, on-chain data and DeFi
- 3.7.4 ccxt for trading on multiple exchanges
- 3.7.5 MiCA: the EU framework for crypto-assets and their service providers (CASPs)
Why options backtests fail because of the data
A daily equity backtest works with one price per asset and day. An options one, with a chain: dozens of strikes per expiry, several expiries, calls and puts, each contract with its bid, its ask, its volume and its open interest. The daily chain of the ORX index in the course data has about 180 rows per session with only three expiries; that of a real index, with weekly and daily expiries, has many more. With so many prices, data errors stop being exceptions and become a population: quotes that are not updated, bids above the ask, days without data. And with bid-ask spreads that, for out-of-the-money options, reach a large fraction of the price, the execution assumption weighs more than in any other asset.
Mermaid source
flowchart TB D[Raw chain] --> L[Cleaning] L --> S[Selection at t] S --> E[Entry fill] E --> V[Daily valuation] V --> X[Expires or is closed] X --> M[Metrics and tail]
Each box has its trap. Selection must use only what was known at that day's close (the 0.16-delta strike is
computed with that close's implied volatility, not tomorrow's). The fill decides how much is collected or paid.
Daily valuation at the mid needs a valid quote every day for each leg. Expiry settles at intrinsic value or,
for American options, can come earlier through exercise (lesson 3.5.5). This lesson walks through the boxes with
synthetic data that carry planted defects and the list of those defects (opciones3_incidencias.csv): the known
answer against which to measure each cleaning rule.
Real data sources
In the US, all options exchanges publish their quotes and trades through a single processor, OPRA (Options Price Reporting Authority). According to its website, OPRA is a registered securities information processor whose members are the SEC-authorized options exchanges, and what it distributes is, above all, trades (“last sale”) and quotes, plus volumes, open interest and end-of-day summaries. Everything else is derived from that feed:
| Product | What it provides | Granularity | Typical use |
|---|---|---|---|
OPRA feed (e.g. Databento OPRA.PILLAR) | Consolidated best bid/ask, trades, statistics, definitions | Every message, or aggregated per second or minute | Microstructure, execution, intraday |
| End-of-day summary (e.g. Cboe Option EOD Summary) | Bid/ask with size at 15:45 ET and at the close; OHLC, volume and open interest at the close | Two daily snapshots, since January 2012 | Daily backtests |
| Historical chains with implied volatilities and Greeks (academic and commercial vendors) | The above plus computed implied volatilities, Greeks and surfaces | Daily | Research, surfaces |
Two details in the table matter in a daily backtest. The first: Cboe provides one snapshot at 15:45 and another at the close because the close of the options and that of the underlying are not simultaneous, and an implied volatility computed with prices from different times is wrong. The 15:45 snapshot includes, according to its page, the optional implied volatilities and Greeks computed at that time. The second: Databento states that before 2023-03-28 its OPRA history comes from subsampled data, with the per-minute BBO as the finest detail; for earlier dates there are no message-by-message quotes.
The code to request from Databento one day of quotes for SPX options is this (it needs a key and network access; it
is not run here). The parent symbology with the .OPT suffix groups all the options of a root, according to its
documentation:
import databento as db
client = db.Historical("KEY")
datos = client.timeseries.get_range(
dataset="OPRA.PILLAR",
schema="cbbo-1m", # consolidated best bid/ask per minute
stype_in="parent",
symbols=["SPX.OPT"],
start="2024-03-15T19:00:00",
end="2024-03-15T20:00:00",
)
df = datos.to_df()Each contract arrives with its OSI symbol, the clearing house (OCC) format: 21 characters with the six-character root
padded with spaces, the expiry date YYMMDD, C or P and the strike times 1,000 in eight digits (Databento's
example is SPY 231117P00486000). Building and parsing it is the first thing any code that joins data from two
sources does:
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}The course chain and its gaps
The ORX chain (opciones3_cadena.parquet) mimics SPX options: European, cash-settled at the close of expiry day
(a simplification: on the real S&P 500, the monthlies settle at the open), a 100 USD multiplier, a tick of 0.05 below
3 points and 0.10 above, and the three nearest monthly expiries. Before any strategy, four questions: how many
contracts there are, which sessions are missing, which are anomalous and what the bid-ask spreads look like.
por_dia = cad.groupby("fecha").size()
print("rows per session:", por_dia.describe()[["min", "50%", "max"]]
.astype(int).to_dict())
faltan = ind.index.difference(cad["fecha"].unique())
print("index sessions without a chain:", [d.date() for d in faltan])
cortas = ind.index[ind["sesion_corta"]]
vol = cad.groupby("fecha")["volumen"].sum()
print(f"half sessions: {len(cortas)}; average volume versus a "
f"normal session: {vol.reindex(cortas).mean() / vol.mean():.0%}")rows per session: {'min': 86, '50%': 174, 'max': 396}
index sessions without a chain: [datetime.date(2019, 7, 23)]
half sessions: 26; average volume versus a normal session: 35%On 2019-07-23 the index has a close and the chain does not: the planted day without a chain. A backtest that does
cadena[cadena.fecha == d] that day receives an empty table and, depending on how it is written, opens positions with
a NaN price, values the portfolio at zero or crashes. The reasonable rule is explicit: nothing is opened that day and
the valuation is repeated (or interpolated, and stated). The 26 half sessions (13:00 close on the NYSE) have a third of
the volume: they are not an error, but their quotes are less representative, and that day's theta is that of a full
calendar day.
The bid-ask spreads, as a % of the mid, by distance to the money (out-of-the-money options, with a positive bid), and the fraction of zero bids:
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"zero bids: {(cad['bid'] == 0).mean():.1%} of the rows")
pct[ok].groupby(tramos[ok], observed=True).median().round(1)zero bids: 11.1% of the rows
<2% 1.2
2-5% 1.8
5-10% 2.9
10-15% 4.5
>15% 6.7
dtype: float64At the money, the median bid-ask spread is 1.2% of the price; between 5 and 10% out, 2.9%; beyond 15%, 6.7%: the figures of the README's known answer. The figure separates puts and calls: because of the skew, out-of-the-money calls are worth very little, and a similar spread in points is a much larger percentage of their price. And 11.1% of the rows have a zero bid: options so far out of the money that nobody pays anything for them. In those rows the mid (half the ask) is a price that does not exist: nobody buys at that price and nobody can sell. Selling a zero-bid option in a mid-priced backtest is manufacturing premium.
Cleaning: crossed, stale and parity
Three rules, each checked against the list of planted issues. The first is the easiest: a bid above the ask (a crossed quote) cannot be an executable quote.
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: int64The 12 crossed quotes in the chain are exactly the 12 planted ones. Stale quotes are harder: yesterday's quote repeated today. The generator planted them on far options (absolute delta below 0.12) on days when the index moved more than 1%. The obvious rule, “same as yesterday on a day with a move”, and a version that excludes zero bids:
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 = {"same and |r| > 1%": cad["igual"] & cad["mueve"],
"... and 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
same and |r| > 1% 11336.0 1500.0 0.132 1.000
... and bid > 0 1940.0 1090.0 0.562 0.727The first rule finds all of them (100% recall), but only one in eight flagged quotes is a planted stale quote: the
rest are options with a bid of 0 and an ask at the minimum tick, whose quote does not change because they are worth
almost nothing. Requiring a positive bid raises precision to 56% and lets 27% of the stale quotes slip through: the
ones with the least value. Do the ones that slip through matter? To find out, measure the price error of each one
against the model value (with iv_modelo, the true implied volatility; in real data it does not exist and it would
have to be compared with the smile of the neighboring strikes):
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],
["not planted", "planted, bid > 0"], "planted, bid 0")
cg.groupby("grupo")["error_semis"].describe(
percentiles=[0.5, 0.9])[["count", "50%", "90%", "max"]].round(2) count 50% 90% max
grupo
not planted 9836.0 0.97 1.00 1.00
planted, bid 0 410.0 0.99 1.00 33.52
planted, bid > 0 1090.0 10.74 34.33 219.42The error is measured in half-spreads: 1 means the mid is one half-spread away from the value, the threshold beyond which one could buy or sell at a profit. The planted stale quotes with a positive bid have a median error of 10.7 half-spreads, 34 at the 90th percentile: a backtest that uses them to enter or to value “earns” or “loses” a price that did not exist. The unplanted ones do not exceed one half-spread (the mid of a 0 and 0.05 quote is half a spread away from an almost zero value), and neither do the planted ones with a zero bid, except a few: there is no need to clean them to avoid mistakes. The useful rule is not “remove everything that does not change”, but “do not execute against a quote that has not changed when the model says it should have”.
The third rule is put-call parity (lesson 3.4.1): for a European call and put with the same strike and expiry, . In real data the forward is not known, but the chain itself provides it: each pair implies , and the median of the of the seven strikes closest to the money is a robust estimate. With it, flag the pairs that deviate from parity by more than the sum of their half-spreads:
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"estimated forward versus the true one: median error "
f"{(cmp_.F_est - cmp_.forward).abs().median():.3f} points")
print(f"pairs outside parity: {len(fuera)} of {len(pc)}")
inc1 = inc.drop_duplicates(["fecha", "vencimiento", "strike"])
fuera.merge(inc1, on=["fecha", "vencimiento", "strike"],
how="left")["incidencia"].value_counts(dropna=False)estimated forward versus the true one: median error 0.021 points
pairs outside parity: 346 of 272274
incidencia
cotizacion_congelada 330
paridad_violada 8
cotizacion_cruzada 8
Name: count, dtype: int64The estimated forward is off by 0.02 points at the median on an index in the thousands: the parity of the nearby strikes pins it down very precisely. 346 pairs out of 272,274 fall outside, and all of them have a planted issue: 330 because of a stale leg, the 8 parity violations and 8 of the crossed quotes. It is the README's known answer (345 pairs with the true forward, all with an issue). In practice it is the best rule of the three, because it needs no model: a parity violation larger than the spreads on liquid European options is, almost always, a bad data point and not an arbitrage.
Mermaid source
flowchart TB
Q[Quote] --> A{bid greater than ask}
A -- yes --> X1[Discard]
A -- no --> B{zero bid}
B -- yes --> X2[Do not sell]
B -- no --> C{Parity broken}
C -- yes --> X3[Suspect]
C -- no --> D{Same as yesterday}
D -- yes --> X4[Do not execute]
D -- no --> OK[Use]The engine: selection, fills and valuation
The engine in the next cell sells (or buys) a structure every month: on the day of a monthly expiry it opens the legs
in the next expiry with at least 20 days, chooses each strike with the information of that close (Black-Scholes delta
with that day's implied volatility, or the ATM strike), values each day at the mid, repeats the valuation on the day
without a chain and settles at expiry at intrinsic value. Cash earns the 1-month rate. The frac parameter is the fill
model: the fraction of the half-spread paid on entry and exit. frac = 1 sells at the bid and buys at the ask;
frac = 0, everything at the mid; frac = 0.5, halfway, a common assumption for patient limit orders. cierre
allows unwinding the position before expiry when its value falls below a fraction of the credit collected (lesson
3.5.5), with a second crossing of the spreads.
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: the highest strike below the index
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: (type, target delta or "atm", sign: -1 sells)."""
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 on the day without a chain
if venc is not None and f == venc: # settlement
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: # opens the next one
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()}The first test is the README's known answer: the ATM put-write with cash collateral (notional = strike, like Cboe's PUT index) gives a Sharpe of 0.52 and 6.8% a year at the bid, and 0.53 and 6.9% at the mid.
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.00It matches the README. On a one-month ATM put, the bid-ask spread is small relative to the premium, and selling at the bid instead of at the mid costs a tenth of a point of return per year. Here the execution assumption barely matters. On a four-leg out-of-the-money structure, it does: an iron condor sells the 0.16-delta put and call and buys the 0.05-delta put and call as wings (lesson 3.5.2), with notional = index; and its version with early close unwinds it when it has earned half the credit.
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.000The horquilla column adds up what was paid in half-spreads per unit of notional over the whole sample. In the condor
held to expiry, executing at the bid/ask instead of at the mid lowers the Sharpe from 0.10 to 0.05; with early close,
which crosses the spreads twice, from 0.36 to 0.27. The annual return changes little in points (a few tenths), but the
condor's excess return is small, and the bid-ask spread takes a large part of it. A backtest of such a structure
executed at the mid says almost nothing; one at the bid/ask is pessimistic for someone who works with limit orders;
the frac 0.5 row is the honest assumption if there is no data from one's own executions.
Events: dividends, earnings and early exercise
Options on a European index have few events: the index dividends are in the forward (that is why the lesson uses
and not for moneyness), and nothing is exercised early. Options on stocks have three. The fictitious stock KRN in
the data (accion3_diario.csv, accion3_eventos.csv, accion3_opciones.parquet) mimics a large US industrial with a
quarterly dividend, earnings four times a year and American options.
The first is the reference price. Vendors of daily series usually provide a close adjusted for dividends (and for splits): they rewrite the past so that returns are continuous. Option contracts, on the other hand, were listed with the prices of the time. For moneyness, strike selection and intrinsic value, the price is always the unadjusted one:
krn = pd.read_csv("accion3_diario.csv", parse_dates=["fecha"])
krn = krn.set_index("fecha")
div = krn["dividendo_ex"]
# backward adjustment, the vendors' one: factor 1 - D / close
# of the day before the ex-date, accumulated into the past
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()}: unadjusted {krn['cierre'][d0]:.2f}, "
f"adjusted {ajustado[d0]:.2f} "
f"({ajustado[d0] / krn['cierre'][d0] - 1:.1%})")
print(f"2.5 USD strike steps of difference: "
f"{(krn['cierre'][d0] - ajustado[d0]) / 2.5:.1f}")2022-01-03: unadjusted 117.52, adjusted 105.62 (-10.1%)
2.5 USD strike steps of difference: 4.8With the 16 subsequent dividends, the adjusted close of January 3, 2022 ends up 10.1% below the real one (105.62 versus 117.52 USD): almost five strike steps. A backtest that chooses the “ATM” strike with the adjusted series buys options in or out of the money without knowing it, and computes their intrinsic value at expiry wrongly. Splits are worse (the adjustment cuts the price in half or to a third), and for them the clearing house also adjusts the contracts: the number of shares each contract delivers or the strike changes, and the adjusted contract gets a different symbol. Ordinary dividends, by contrast, do not adjust the contracts: their price discounts them1Note 1 · What the clearing house does is summarized in the SEC notice on OCC proposal SR-OCC-2006-01 (Federal Register 71 FR 67415, November 21, 2006): “as a general rule, outstanding options will not be adjusted to compensate for ordinary cash dividends”, while extraordinary dividends, splits and spin-offs are adjusted: https://www.govinfo.gov/content/pkg/FR-2006-11-21/html/E6-19619.htm. The specific rules in force are in the OCC by-laws (Article VI, Section 11A)..
The second event is earnings. Before an announcement, the implied volatility of the nearest expiry includes the variance of the expected jump; the next day, the jump has already happened and implied volatility falls. For 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):
"""ATM call of the front expiry with the day's close."""
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 # announcement in the last session
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"|move| > event sigma: "
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}
|move| > event sigma: 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.1The ATM implied volatility falls on average from 33.2% to 24.6% from the close of the announcement to the next, with a planted standard deviation of the move of 5.7%. It is the README's known answer. The lesson for a backtest is not that selling it wins: it is that implied volatility around a known event cannot be compared with that of other days. A rule of the type “sell when implied volatility is at its 90th percentile” will systematically sell before earnings, and its result will depend on the jump days, which here exceed their planted standard deviation in 31% of the announcements. Earnings are evaluated event by event, with their worst case.
The third is early exercise. An American call on a dividend-paying stock can be worth more alive than exercised,
except on the eve of the ex-date, when exercising it captures the dividend; a deep in-the-money American put is
exercised early with high rates, because collecting the strike today earns interest (lesson 3.4.2). In the data,
precio_modelo is the American one (binomial tree with discrete dividends) and europea_modelo the same option as
European:
ops["prima_ej"] = ops["precio_modelo"] - ops["europea_modelo"]
print(f"american > european + 0.01: "
f"{(ops['prima_ej'] > 0.01).mean():.0%} of the rows; "
f"maximum {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))
# exercising is optimal if the American is worth no more than its intrinsic
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)american > european + 0.01: 50% of the rows; maximum 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.000The early exercise premium exists in half the rows and reaches 2.35 USD per share. The table measures something else: in what fraction of in-the-money options exercising is already optimal (the American is worth its intrinsic value). For calls, 0.5% of normal days and 7.1% on the eve of an ex-date, fourteen times more: that is when the holder exercises to collect the dividend, and whoever sold it gets assigned and must deliver the shares (and the dividend). For puts it is the other way around: exercising is optimal on 21.5% of normal days, because of rates of 4-5% (collecting the strike today earns interest), and never on the eve of an ex-date, because the price drop from the dividend the next day is worth more than waiting a day. A backtest that treats these options as European and holds them to expiry overvalues covered call sales before dividends and misses the assignment risk, which changes the position from one day to the next.
Options backtest checklist
- Data. Snapshot at the same time for options and underlying (15:45 or close, not mixed). Strikes by unadjusted price. Days without a chain and half sessions identified.
- Cleaning. Crossed quotes out; zero bids are not sold; parity with the chain's own forward; quotes unchanged on days with a move, no execution against them.
- Selection. Strikes and deltas with information from the decision close; implied volatilities computed from that day's prices, not a column from the future.
- Execution. At least two fill assumptions (mid and bid/ask) and, if possible, an intermediate one; per-contract commissions; early close crosses the spread again.
- Events. Dividends and earnings on the calendar; early exercise and assignment on American options; contract adjustments for splits and corporate actions.
- Results. Worst month, maximum drawdown and its date next to the Sharpe; a premium-selling strategy without its worst episode has not been evaluated (lesson 3.6.1).
Exercises
- Improve the stale-quote rule: in addition to “same as yesterday on a day with a move”, require the model to say
that the option should have moved more than one half-spread (use
error_semisas a proxy). What precision and what recall do you get?
Solution
marc = cg[cg["error_semis"] > 1]
acierto = marc["incidencia"].notna().sum()
print(f"flagged {len(marc)}, hits {acierto}, "
f"precision {acierto / len(marc):.1%}, "
f"recall {acierto / len(verdad):.1%}")flagged 1049, hits 1037, precision 98.9%, recall 69.1%Precision rises a lot: almost everything the rule flags is a planted stale quote. Recall falls, but what slips through are stale quotes whose price is less than one half-spread from the value: executing against them neither gives nor takes anything the spread does not cover. In real data, the model value is replaced by the implied volatility interpolated from the neighboring strikes on that day's smile (lesson 3.4.5).
- For the iron condor at the bid/ask, compute what part of the excess return over the collateral the bid-ask spread takes compared with the same condor at the mid.
Solution
caja_media = np.exp(r1m.mean()) - 1 # cash grows e^(r/252) per day
exc_bid = tabla.loc["condor, frac 1", "anual"] - caja_media
exc_med = tabla.loc["condor, frac 0", "anual"] - caja_media
print(f"average compounded 1-month rate: {caja_media:.2%}")
print(f"excess at the mid {exc_med:.2%}, at the bid/ask {exc_bid:.2%}: "
f"the spread takes {1 - exc_bid / exc_med:.0%}")average compounded 1-month rate: 1.82%
excess at the mid 0.35%, at the bid/ask 0.13%: the spread takes 62%On the total return, the difference between fills looks small; on the excess return over the collateral, which is what the strategy earns, the bid-ask spread takes a large part. That is the right way to present the execution cost of an options strategy: against what it earns, not against the capital.
- A backtest of selling weekly straddles on a real index, with end-of-day data, gives a Sharpe of 3. List the checks from this lesson you would run before believing it.
Solution
Whether the fills are at the mid or at the last price (repeat with bid/ask and with frac 0.5; on weekly options the bid-ask spread weighs much more relative to the premium). Whether the options snapshot and the underlying snapshot are from the same time (an implied volatility computed with an options close at 16:15 and an index close at 16:00 is biased). Whether there are stale or crossed quotes on the legs sold and whether the strategy sells options with a zero bid. Whether strike selection uses next-day data (implied volatilities recalculated after the fact). What it does on the day without data and on the half session. How it settles: at the open or at the close, with what reference price. And its worst week: selling weekly straddles has its tail in one-session jumps, and a Sharpe of 3 over twelve years without a catastrophic week is more likely a data error than an edge.
Footnotes
-
What the clearing house does is summarized in the SEC notice on OCC proposal SR-OCC-2006-01 (Federal Register 71 FR 67415, November 21, 2006): “as a general rule, outstanding options will not be adjusted to compensate for ordinary cash dividends”, while extraordinary dividends, splits and spin-offs are adjusted: https://www.govinfo.gov/content/pkg/FR-2006-11-21/html/E6-19619.htm. The specific rules in force are in the OCC by-laws (Article VI, Section 11A). ↩
Each answer is folded under its question
Self-assessment
A rule flags as stale every quote equal to the previous day's on a day when the index moved more than 1%. It finds the 1,500 planted ones and another 9,836. Where do the others come from?
- AMostly from far out-of-the-money options with a bid of 0 and an ask at the minimum tick: their quote does not change because they are worth almost nothing, not because they are stale
- BFrom errors in the data generator
- CFrom the 2019-07-23 session
- DFrom the half sessions
Show answer
Answer A. Mostly from far out-of-the-money options with a bid of 0 and an ask at the minimum tick: their quote does not change because they are worth almost nothing, not because they are stale
An option with a bid of 0 and an ask of 0.05 can stay that way even if the index moves 1%. Requiring a bid above zero raises precision from 13% to 56%, at the cost of missing 27% of the real stale quotes. The stale quotes that slip through are the ones with the least value.
With the forward estimated from the chain itself, 346 put-call pairs are outside parity by more than their half-spread. What are they?
- AArbitrage opportunities
- BErrors in the forward calculation
- CThe dividend effect
- DData defects: all 346 match a planted issue (330 stale, 8 parity violations and 8 crossed)
Show answer
Answer D. Data defects: all 346 match a planted issue (330 stale, 8 parity violations and 8 crossed)
In clean data, parity holds within the spreads; what falls outside is almost always a bad quote. A backtest that “buys the conversion” on those pairs would be trading against a broken data point.
A monthly ATM put-write has a Sharpe of 0.53 at the mid and 0.52 at the bid. A 16- and 5-delta iron condor, 0.10 and 0.05. Why such a difference?
- ABecause the condor has more tail risk
- BBecause the condor crosses four bid-ask spreads, three of them on out-of-the-money options, where the spread is several times larger relative to the premium, and it collects a small net premium
- CBecause the put-write uses cash collateral
- DBy chance
Show answer
Answer B. Because the condor crosses four bid-ask spreads, three of them on out-of-the-money options, where the spread is several times larger relative to the premium, and it collects a small net premium
The execution cost is measured against the net premium, not against the notional. The ATM put has a bid-ask spread of 1.2% of its price; the condor legs, several times that, and the net premium is small.
To choose the ATM strike of a 2022 stock option, the dividend-adjusted close from a vendor downloaded in 2025 is used. What goes wrong?
- ANothing, the adjustment does not affect options
- BThat dividends are paid on another date
- CThe 2022 adjusted close is below that day's real price (for KRN, 10.1% on January 3, 2022); strikes refer to the unadjusted price, so the “ATM” strike ends up several steps in or out of the money
- DThat American options have no strike
Show answer
Answer C. The 2022 adjusted close is below that day's real price (for KRN, 10.1% on January 3, 2022); strikes refer to the unadjusted price, so the “ATM” strike ends up several steps in or out of the money
The dividend adjustment rewrites the past so that returns are continuous; option contracts were listed with the prices of the time. For moneyness and intrinsic value, always use the unadjusted price.
For the KRN stock, the ATM implied volatility of the front expiry falls on average from 33.2% to 24.6% from the day of the earnings announcement to the next. What does it imply for a backtest?
- AThat it pays to buy options before earnings
- BThat implied volatility around a known event is not comparable to that of other days: a backtest that sells “expensive” volatility before earnings has to account for the next day's move, which for KRN has an average planted standard deviation of 5.7%
- CThat the data is wrong
- DThat vega is zero after the announcement
Show answer
Answer B. That implied volatility around a known event is not comparable to that of other days: a backtest that sells “expensive” volatility before earnings has to account for the next day's move, which for KRN has an average planted standard deviation of 5.7%
Implied volatility before the announcement includes the variance of the expected jump. Selling it can earn the drop in implied volatility on average, but it loses on the days when the move exceeds what was priced in: it has to be measured event by event, with the tail.
Key points
- OPRA is the processor that consolidates quotes and trades from all US options exchanges; end-of-day summaries and historical chains are derived from it. Contracts are identified with 21-character OSI symbols.
- Cleaning a chain is measuring: in the course data, all 12 crossed quotes are detected; stale quotes, with a precision that depends on excluding worthless options; and parity with a forward estimated from the chain itself flags 346 pairs, all defective.
- The fill decides the result when the net premium is small: at the bid/ask, the iron condor's Sharpe falls from 0.10 to 0.05 and that of the condor with early close, from 0.36 to 0.27, while the put-write barely changes.
- Days without a chain, half sessions, zero bids and strikes that appear and disappear require explicit rules: repeat the valuation, do not trade, do not sell at zero.
- For stocks, strikes are compared with unadjusted prices; earnings inflate implied volatility before the announcement and dividends bring forward the exercise of American calls.