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.

Fig. — Sharpe ratio of three monthly premium-selling strategies on ORX with fills at mid and at bid/ask (2014-2025, synthetic data): the smaller the net premium and the more legs crossed, the more the spread weighs
Level 3 lessons
  1. 3.1 · Futures in depth

    1. 3.1.1 Main futures contracts
    2. 3.1.2 Futures curve: basis, cost of carry, contango and roll yield
    3. 3.1.3 Roll management, margins, SPAN and implied leverage
    4. 3.1.4 Diversified futures systems: Carver's approach
  2. 3.2 · Futures spreads

    1. 3.2.1 Calendar spreads, butterflies and condors
    2. 3.2.2 Intercommodity spreads: crack, spark and crush
    3. 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
    4. 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
    5. 3.2.5 Spread data and backtesting: listed, synthetic and legged
    6. 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
  3. 3.3 · Advanced forex

    1. 3.3.1 FX microstructure: OTC, ECN, last look and sessions
    2. 3.3.2 Carry and crash risk, momentum and value in currencies; baskets
    3. 3.3.3 Currency risk in multi-currency portfolios; execution via OANDA, IBKR or MT5
  4. 3.4 · Option theory and pricing

    1. 3.4.1 Payoffs, put-call parity and arbitrage bounds
    2. 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
    3. 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
    4. 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
    5. 3.4.5 Smile, skew and term structure; volatility surface with SVI
    6. 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
    7. 3.4.7 Advanced models: Heston, SABR and local volatility
  5. 3.5 · Option structures in Python

    1. 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
    2. 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
    3. 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
    4. 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
    5. 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
  6. 3.6 · Systematic volatility and options strategies

    1. 3.6.1 Systematic premium selling: put-write, strangles and their tail risk
    2. 3.6.2 Delta hedging, gamma scalping and dispersion
    3. 3.6.3 VIX and VSTOXX strategies: volatility futures and ETPs
    4. 3.6.4 Options backtesting: data, fills and event adjustments
  7. 3.7 · Crypto derivatives and specific strategies

    1. 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
    2. 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
    3. 3.7.3 Crypto options, on-chain data and DeFi
    4. 3.7.4 ccxt for trading on multiple exchanges
    5. 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.

Raw chainCleaningSelection at tEntry fillDaily valuationExpires or is closedMetrics and tail
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:

ProductWhat it providesGranularityTypical use
OPRA feed (e.g. Databento OPRA.PILLAR)Consolidated best bid/ask, trades, statistics, definitionsEvery message, or aggregated per second or minuteMicrostructure, 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 closeTwo daily snapshots, since January 2012Daily backtests
Historical chains with implied volatilities and Greeks (academic and commercial vendors)The above plus computed implied volatilities, Greeks and surfacesDailyResearch, 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:

IN [01]python
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:

IN [02]python
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"))
OUT [02]OSI symbols of the ORX chain
'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.

IN [03]python
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%}")
OUT [03]Size, missing sessions and half sessions
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:

IN [04]python
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)
OUT [04]Relative bid-ask spread by distance to the money
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: float64
Fig. — Median spread of out-of-the-money ORX options as % of mid price, by distance from the money (1% buckets, positive bid, 2014-2025; synthetic data)

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

IN [05]python
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()
OUT [05]Crossed quotes
{'cotizacion_congelada': 1500, 'cotizacion_cruzada': 12, 'paridad_violada': 8, 'dia_sin_cadena': 1}
incidencia
cotizacion_cruzada    12
Name: count, dtype: int64

The 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:

IN [06]python
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)
OUT [06]Stale quotes: two rules and their precision
                   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.727

The 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):

IN [07]python
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)
OUT [07]How wrong a stale quote is
                   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.42

The 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, C−P=e−rT(F−K)C - P = e^{-rT}(F - K). In real data the forward is not known, but the chain itself provides it: each pair implies FK=K+erT(C−P)F_K = K + e^{rT}(C - P), and the median of the FKF_K 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:

IN [08]python
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)
OUT [08]Put-call parity with the chain's own forward
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: int64

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

yesnoyesnoyesnoyesnoQuotebid greater than askDiscardzero bidDo not sellParity brokenSuspectSame as yesterdayDo not executeUse
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.

IN [09]python
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.

IN [10]python
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)
OUT [10]Put-write: at the bid versus at the mid
                   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.00

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

IN [11]python
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)
OUT [11]Iron condor: four spreads and a second crossing
                    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.000

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

Fig. — Wealth of the ATM put-write at bid and of the iron condor closed at 50% of credit at mid and at bid/ask, month-end, 2014-2025 (synthetic data; includes the 1-month rate on cash)

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 FF and not SS 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:

IN [12]python
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}")
OUT [12]Adjusted versus unadjusted close
2022-01-03: unadjusted 117.52, adjusted 105.62 (-10.1%)
2.5 USD strike steps of difference: 4.8

With 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:

IN [13]python
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)
OUT [13]Implied volatility before and after earnings
{'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.1
Fig. — Implied volatility of KRN's front-month ATM call around its 16 earnings releases, mean by session, 2022-2025 (synthetic data)

The 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:

IN [14]python
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)
OUT [14]Early exercise premium
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.000

The 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

  1. 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_semis as a proxy). What precision and what recall do you get?
Solution
IN [15]python
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%}")
OUT [15]Stale quotes that matter
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).

  1. 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
IN [16]python
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%}")
OUT [16]The bid-ask spread versus the excess return
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.

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

  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). ↩

Each answer is folded under its question

Self-assessment

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

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

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

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

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

Resources