---
title: "3.6.4 Options backtesting: data, fills and events"
description: "Historical chains and OPRA, mid fills versus bid/ask, stale and crossed quotes, parity, days without a chain, dividends and earnings."
url: "https://pythonparatrading.com/en/python-trading-course/level-3/options-backtesting/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-3/options-backtesting.png"
---

# 3.6.4 · 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.

- Course: [Level 3: Derivatives: futures, spreads, forex and options](https://pythonparatrading.com/en/python-trading-course/level-3/)
- Module: 3.6 · Systematic volatility and options strategies
- Duration: 75 min
- Prerequisites: [3.4.1 · Payoffs, put-call parity and arbitrage bounds](https://pythonparatrading.com/en/python-trading-course/level-3/option-payoffs-put-call-parity-arbitrage/), [3.5.5 · Management: adjustments, rolls, early closes, early assignment and pin risk](https://pythonparatrading.com/en/python-trading-course/level-3/managing-option-positions/), [3.6.1 · Systematic premium selling: put-write, strangles and their tail risk](https://pythonparatrading.com/en/python-trading-course/level-3/systematic-premium-selling-tail-risk/)

## By the end of this lesson

- Describe the US options data sources (OPRA, end-of-day summaries, historical chains) and what each one provides, with OSI symbology.
- Detect crossed quotes, stale quotes and put-call parity breaks in a chain, days without a chain and zero bids, and measure the precision of each rule against an answer key.
- Build an options backtest with look-ahead-free strike selection, daily mark-to-mid, settlement at expiry and a configurable fill model, and measure how much the bid-ask spread takes.
- Handle the events of a stock: unadjusted prices for strikes, the drop in implied volatility after earnings and early exercise of American options.

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

```python skip
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:

```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"))
```
```output
'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.

```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%}")
```
```output
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:

```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)
```
```output
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).* Two curves rising with distance from the money from 1.1% at the money, highlighted. Puts rise slowly: 1.8% at 5 points away, 2.7% at 10 and about 5% at 20. Calls much faster, because out of the money they are very cheap: 3.6% at 5 points, 8.0% at 10 and over 15% in the wings.

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.

```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()
```
```output
{'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:

```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)
```
```output
                   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):

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

```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)
```
```output
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.

```mermaid
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.

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

```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)
```
```output
                   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.

```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)
```
```output
                    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).* Three lines starting at 1. The put-write ends at 2.19 with sharp falls in 2018, 2020 and 2022. The two condors rise slowly and almost in parallel: at mid it ends at 1.41 and at bid/ask, highlighted, at 1.36; the gap between them widens with every trade.

<aside class="callout" data-kind="riesgo">

The condor with early close has the best Sharpe in the table and a small maximum drawdown in this sample. It is not an
income stream: it sells the tail in both directions, its worst month is in the table and its 0.05-delta wings limit the
loss per trade, not the frequency of losses. The figures come from synthetic data with a planted variance risk
premium; they are not evidence that the structure wins on the real S&P 500 (lesson 3.6.1).

</aside>

## 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 $F$
and not $S$ 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:

```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}")
```
```output
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 them[^1].

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:

```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)
```
```output
{'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).* Line with eleven points. Implied vol is around 26.5% five sessions before, rises to 31.8% two sessions before, when the front expiry becomes one that includes the announcement, reaches 33.2% on the announcement day and falls to 24.6% the next day, highlighted; it then stays around 24.6-25.0%.

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:

```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)
```
```output
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.**

```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%}")
```
```output
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).

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

```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%}")
```
```output
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.

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

[^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).

## 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?

   - 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
   - B. From errors in the data generator
   - C. From the 2019-07-23 session
   - D. From the half sessions

   *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?

   - A. Arbitrage opportunities
   - B. Errors in the forward calculation
   - C. The dividend effect
   - D. Data defects: all 346 match a planted issue (330 stale, 8 parity violations and 8 crossed)

   *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?

   - A. Because the condor has more tail risk
   - 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
   - C. Because the put-write uses cash collateral
   - D. By chance

   *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?

   - A. Nothing, the adjustment does not affect options
   - B. That dividends are paid on another date
   - 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
   - D. That American options have no strike

   *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?

   - A. That it pays to buy options before earnings
   - 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%
   - C. That the data is wrong
   - D. That vega is zero after the announcement

   *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

### Documentation

- [OPRA: Options Price Reporting Authority (information processor of the US options exchanges)](https://www.opraplan.com/) · opraplan.com
- [Databento: OPRA.PILLAR dataset (schemas and OSI symbology)](https://databento.com/docs/venues-and-datasets/opra-pillar) · databento.com
- [Databento: timeseries.get_range (historical API)](https://databento.com/docs/api-reference-historical/timeseries/timeseries-get-range) · databento.com
- [Cboe DataShop: Option EOD Summary (15:45 ET and closing snapshots)](https://datashop.cboe.com/option-eod-summary) · datashop.cboe.com

### Data

- [Level 3 data: planted issues in the ORX chain (CSV)](https://pythonparatrading.com/curso/datos/nivel-3/opciones3_incidencias.csv) · Download · opciones3_incidencias.csv
- [Level 3 data: American options on the KRN stock, 2022-2025 (Parquet)](https://pythonparatrading.com/curso/datos/nivel-3/accion3_opciones.parquet) · Download · accion3_opciones.parquet

Previous lesson: [3.6.3 · VIX and VSTOXX strategies: volatility futures and ETPs](https://pythonparatrading.com/en/python-trading-course/level-3/vix-and-vstoxx-strategies/)

Next lesson: [3.7.1 · Perpetuals: funding mechanics, mark price and liquidations](https://pythonparatrading.com/en/python-trading-course/level-3/perpetual-futures-funding-mark-price-liquidations/)
