---
title: "3.4.1 Payoffs, put-call parity and arbitrage bounds"
description: "Payoff replication, Merton bounds and put-call parity tested on twelve years of chain data: which violations are bad data and which are arbitrage."
url: "https://pythonparatrading.com/en/python-trading-course/level-3/option-payoffs-put-call-parity-arbitrage/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-3/option-payoffs-put-call-parity-arbitrage.png"
---

# 3.4.1 · Payoffs, put-call parity and arbitrage bounds

> Before any model, no-arbitrage already bounds the price of an option: bounds, slopes, convexity and put-call parity. This lesson turns those bounds into a scanner, runs it over twelve years of chain data on the fictitious ORX index and separates the violations that are bad data from the eight that were planted on purpose.

- Course: [Level 3: Derivatives: futures, spreads, forex and options](https://pythonparatrading.com/en/python-trading-course/level-3/)
- Module: 3.4 · Option theory and pricing
- Duration: 65 min
- Prerequisites: [1.2.5 · Options: calls, puts, intrinsic value and time value](https://pythonparatrading.com/en/python-trading-course/level-1/options-calls-puts-and-time-value/), [1.4.2 · Data cleaning: missing values, bad ticks, gaps and time zones](https://pythonparatrading.com/en/python-trading-course/level-1/cleaning-financial-market-data/)

## By the end of this lesson

- Build payoffs with calls, puts, forwards and bonds, and derive put-call parity and the Merton bounds from replication.
- Estimate the implied forward of each expiry with the median of the parity and check the parity with executable prices on 272,274 pairs.
- Separate apparent violations due to frozen or crossed quotes from real violations, and confirm the latter with a box spread.
- Check the slope and convexity bounds of a chain and the parity inequalities for American options with discrete dividends.

## Payoffs as building blocks

Lesson 1.2.5 drew the result at expiry of the four basic positions and checked put-call parity on a chain from a
single day. Here we go one step further: payoffs are treated as **pieces** that add up, and from those sums come all
the no-arbitrage relationships that prices must satisfy, without assuming any model of how the underlying moves.

The idea is that of static replication: if two portfolios pay exactly the same at expiry in every scenario, they must
cost the same today; if one always pays at least as much as the other, it cannot cost less. With four pieces (call,
put, forward and zero-coupon bond) almost everything can be built:

| Portfolio | Pays at expiry | Name |
|---|---|---|
| Call bought at K + put sold at K | $S_T - K$ | long synthetic forward |
| Call bought at K1 − call sold at K2 | between 0 and K2 − K1 | call spread (vertical) |
| Synthetic bought at K1 + synthetic sold at K2 | $K_2 - K_1$ always | box spread |
| Call at K1 − 2 calls at K2 + call at K3 | triangle, never negative | butterfly |
| Call spread of width ΔK divided by ΔK | ≈ 1 if $S_T > K$ | digital (approximate) |

```mermaid
flowchart TB
  A[Call bought] --> X[Portfolio A]
  B[Put sold] --> X
  X --> P["Pays S − K"]
  F[Forward at K] --> P
  P --> H[Same price today]
```

Portfolio A (call bought and put sold) and a forward bought at K pay $S_T - K$ in every scenario. A forward at K costs
today the present value of the difference between the market forward $F$ and $K$, so:

$$
C - P = e^{-rT}\,(F - K).
$$

With two strikes, the difference of two synthetic forwards is the **box**: it pays $K_2 - K_1$ whatever happens and is
worth $e^{-rT}(K_2 - K_1)$. It is a fixed-rate loan built with options, and it is the cleanest way to confirm a parity
violation without touching the underlying. The cell builds the payoffs with NumPy on a grid of final prices to see that
the box is flat and the butterfly is never negative:

```python
import numpy as np
import pandas as pd

ST = np.linspace(4000, 6000, 2001)       # index at expiry
call = lambda K: np.maximum(ST - K, 0)
put = lambda K: np.maximum(K - ST, 0)

caja = (call(4900) - put(4900)) - (call(5000) - put(5000))
mariposa = call(4900) - 2 * call(5000) + call(5100)
digital = (call(4995) - call(5005)) / 10
print("box: min", caja.min(), "max", caja.max())
print("butterfly: min", mariposa.min(), "max", mariposa.max())
print("digital at 4,990, 5,000 and 5,010:",
      digital[np.searchsorted(ST, [4990, 5000, 5010])])
```
```output
box: min 100.0 max 100.0
butterfly: min 0.0 max 100.0
digital at 4,990, 5,000 and 5,010: [0.  0.5 1. ]
```

The box pays 100 points across the whole grid; the butterfly is worth between 0 and 100 (maximum at the central
strike); the narrow call spread, divided by its width, jumps from 0 to 1 around the strike like a digital option. From
those three observations come the bounds that follow.

## The Merton bounds

Merton (1973) gathered the conditions that any option price without arbitrage must satisfy. For European options on an
index with forward $F$, discount factor $DF = e^{-rT}$ and strikes $K_1 < K_2 < K_3$:

| Condition | Formula | Why |
|---|---|---|
| Call lower bound | $C \ge DF\,(F - K)^+$ | the call pays at least what the synthetic forward does, and never less than 0 |
| Call upper bound | $C \le DF\,F = S\,e^{-qT}$ | it never pays more than the index |
| Put bounds | $DF\,(K - F)^+ \le P \le DF\,K$ | likewise; it never pays more than the strike |
| Monotonicity | $C(K_1) \ge C(K_2)$, $P(K_1) \le P(K_2)$ | the lower-strike call always pays at least as much |
| Slope | $C(K_1) - C(K_2) \le DF\,(K_2 - K_1)$ | the call spread pays at most $K_2 - K_1$ |
| Convexity | $\lambda C(K_1) + (1-\lambda) C(K_3) \ge C(K_2)$ | the butterfly pays something non-negative; $\lambda = \frac{K_3 - K_2}{K_3 - K_1}$ |

None depends on the volatility or on the distribution of the underlying: they hold for Black-Scholes (lesson 3.4.2),
for Heston (3.4.7) and for any price published by a market without arbitrage. That is why they are the first filter of
any options dataset and, in lesson 3.4.5, the conditions a fitted volatility surface must respect. Convexity also has
an economic meaning: the second derivative of the call price with respect to the strike is, discounted, the
risk-neutral probability density of the index at expiry (Breeden and Litzenberger), and a density cannot be negative.

There is one more bound across expiries (at equal forward moneyness, more time to expiry cannot be worth less), which
with dividends and rates is better expressed in total variance; it is dealt with on the surface in lesson 3.4.5.

## Twelve years of chain data: the implied forward

The data is the daily chain of European options on **ORX**, a fictitious 500-stock index that mimics the S&P 500, from
2014 to 2025: the three nearest monthly expiries, with their bid, ask, volume and open interest. They are cash-settled
at the close of the expiry day, with a multiplier of 100 USD per point, like Cboe's SPX options (the real monthly ones
settle at the open; here, at the close, for simplicity). It is **synthetic data**: the prices come from a known
arbitrage-free surface (`opciones3_verdad.parquet`), with bid-ask spreads, noise and a list of planted defects
(`opciones3_incidencias.csv`). The list is not used to search: it is used at the end, to check whether the scanner
finds all of them and only them.

```python
cad = pd.read_parquet("opciones3_cadena.parquet")
ind = pd.read_csv("opciones3_indice.csv", parse_dates=["fecha"],
                  index_col="fecha")
ancho = cad.pivot_table(index=["fecha", "vencimiento", "strike"],
                        columns="tipo", values=["bid", "ask"])
ancho.columns = [f"{c}_{t.lower()}" for c, t in ancho.columns]
ancho = ancho.dropna().reset_index()
print(len(cad), "options;", len(ancho), "call-put pairs;",
      ancho["fecha"].nunique(), "sessions")
ancho.head(3)
```
```output
544548 options; 272274 call-put pairs; 3017 sessions
       fecha vencimiento  strike  ask_c  ask_p  bid_c  bid_p
0 2014-01-02  2014-01-17  1600.0  227.3    0.2  225.7   0.15
1 2014-01-02  2014-01-17  1650.0  177.4    0.5  176.1   0.45
2 2014-01-02  2014-01-17  1700.0  127.9    1.3  127.1   1.20
```

Checking parity requires $r$ and $F$ for each date and expiry. The rate comes from the curve published in
`opciones3_indice.csv` (continuous rates at 1, 3, 6, 12 and 24 months), linearly interpolated in maturity and flat below
one month, as Cboe does with the VIX. Time is measured in **calendar days / 365**, because options expire at the close
of a specific day and interest also accrues over the weekend.

The forward is not observed: ORX has no future in the data. It is deduced from the chain itself. Solving the parity,
each strike gives an estimate $F_K = K + (C - P)/DF$ with the mid prices; the **median** of the estimates of the strikes
within 3% of the index is robust to one of them being wrong, which is exactly what we want to detect:

```python
plazos = np.array([1 / 12, 0.25, 0.5, 1.0, 2.0])
cols = ["tipo_1m", "tipo_3m", "tipo_6m", "tipo_1a", "tipo_2a"]
ancho["T"] = (ancho["vencimiento"] - ancho["fecha"]).dt.days / 365
curva = ind.loc[ancho["fecha"], cols].to_numpy() / 100
ancho["r"] = [np.interp(t, plazos, c)
              for t, c in zip(ancho["T"], curva)]
ancho["DF"] = np.exp(-ancho["r"] * ancho["T"])
ancho["S"] = ind.loc[ancho["fecha"], "cierre"].to_numpy()
medio_c = (ancho["bid_c"] + ancho["ask_c"]) / 2
medio_p = (ancho["bid_p"] + ancho["ask_p"]) / 2
ancho["F_k"] = ancho["strike"] + (medio_c - medio_p) / ancho["DF"]
cerca = (ancho["strike"] / ancho["S"] - 1).abs() < 0.03
F = (ancho[cerca].groupby(["fecha", "vencimiento"])["F_k"]
     .median().rename("F"))
ancho = ancho.join(F, on=["fecha", "vencimiento"])

verdad = pd.read_parquet("opciones3_verdad.parquet")
cortes = (ancho.drop_duplicates(["fecha", "vencimiento"])
          .merge(verdad, on=["fecha", "vencimiento"]))
err = cortes["F"] - cortes["forward"]
print(f"{len(cortes)} slices; forward error: median "
      f"{err.median():.3f}, p99 {err.abs().quantile(0.99):.3f},"
      f" max {err.abs().max():.3f} points")
dr = (cortes["r"] - cortes["tipo_libre"]).abs().max()
print(f"Maximum rate error: {dr:.1e}")
```
```output
9051 slices; forward error: median 0.000, p99 0.181, max 0.457 points
Maximum rate error: 4.9e-07
```

Across the 9,051 slices (date and expiry) the estimated forward matches the true one with a zero median error, 99% of
the slices within 0.18 points and a maximum of 0.46 points on an index in the thousands. The interpolated rate is the
true one up to rounding. With real data there is no truth to compare against; this step is what allows trusting the
method before using it.

## Parity as a scanner

With $F$ and $DF$, each call-put pair admits two arbitrage trades executable at screen prices:

- **Conversion** (the call is expensive): sell the call at the bid, buy the put at the ask and buy the forward. It earns
  $C_{bid} - P_{ask} - DF\,(F - K)$ if positive.
- **Reversal** or reverse conversion (the put is expensive): buy the call at the ask, sell the put at the bid and sell
  the forward. It earns $DF\,(F - K) - (C_{ask} - P_{bid})$.

A useful property: the two conditions together are equivalent to the parity deviation computed with **mid** prices
exceeding, in absolute value, the **half-sum of the two bid-ask spreads**. It is enough to subtract: $C_{bid} - P_{ask}$
is the mid of $C - P$ minus that half-sum. The cell checks it on the 272,274 pairs:

```python
teo = ancho["DF"] * (ancho["F"] - ancho["strike"])
ancho["conversion"] = ancho["bid_c"] - ancho["ask_p"] - teo
ancho["reversal"] = teo - (ancho["ask_c"] - ancho["bid_p"])
viol = ancho[(ancho["conversion"] > 0) | (ancho["reversal"] > 0)]
print(len(viol), "pairs with apparent arbitrage out of", len(ancho))

desv = medio_c - medio_p - teo
semi = ((ancho["ask_c"] - ancho["bid_c"])
        + (ancho["ask_p"] - ancho["bid_p"])) / 2
print("The same count with mids:", (desv.abs() > semi).sum())
```
```output
346 pairs with apparent arbitrage out of 272274
The same count with mids: 346
```

346 pairs out of 272,274, 0.13%. The header figure shows what one looks like on a specific day: on October 4, 2024,
December expiry (77 days), the deviation of each strike is within its executable band (the gray bars, from minus to
plus the half-sum of the spreads) except for the 4,950 put, the closest to the index, which is 4.09 points below the
line with a band of 1.05.

Before calling anything arbitrage, one has to ask why it would exist. 0.13% of the pairs is little for an efficient
market and a lot for all of them to be free money. The usual suspects are in the data:

- **Frozen quotes.** An option that repeats exactly the previous day's bid and ask in a session in which the index
  moved more than 1% has not been updated: the vendor carries forward the last price of a contract that has not been
  quoted.
- **Crossed quotes.** A bid above the ask is impossible in a working book: it is a capture or synchronization error.

The two rules are applied to each leg, without looking at the list of issues:

```python
cad = cad.sort_values(["vencimiento", "tipo", "strike", "fecha"])
g = cad.groupby(["vencimiento", "tipo", "strike"])
igual = ((cad["bid"] == g["bid"].shift())
         & (cad["ask"] == g["ask"].shift()))
mov = np.log(ind["cierre"]).diff().abs()
cad["congelada"] = igual & (
    mov.reindex(cad["fecha"]).to_numpy() > 0.01)
cad["cruzada"] = cad["bid"] > cad["ask"]
print(cad[["congelada", "cruzada"]].sum().to_dict())

marcas = cad.groupby(["fecha", "vencimiento", "strike"])[
    ["congelada", "cruzada"]].any()
viol = viol.join(marcas, on=["fecha", "vencimiento", "strike"])
viol["causa"] = np.select(
    [viol["cruzada"], viol["congelada"]],
    ["crossed quote", "frozen quote"], "none")
viol["causa"].value_counts()
```
```output
{'congelada': 11336, 'cruzada': 12}
causa
frozen quote     330
none               8
crossed quote      8
Name: count, dtype: int64
```

The frozen-quote rule is crude: it flags 11,336 quotes across the whole chain, most of them far out-of-the-money options
that keep quoting 0 and 0.05 because they are worthless and their price does not change even if the index moves. For the
scanner it does not matter, because those do not generate violations. Of the 346 violations, 330 have a frozen leg and 8
a crossed one. Only 8 remain unexplained in the data.

*Fig. — The 346 executable parity violations in the ORX chain, by cause.* Horizontal bars: 330 violations have a leg with the previous day's stale quote, 8 a crossed quote (bid above ask) and 8, highlighted, no data defect: they are the eight planted violations.

```mermaid
flowchart TB
  V[Violation] --> Q{Crossed?}
  Q -- yes --> D1[Bad data]
  Q -- no --> C{Frozen?}
  C -- yes --> D1
  C -- no --> B{Box confirms?}
  B -- no --> D2[Check F and r]
  B -- yes --> K{Covers costs?}
  K -- yes --> OP[Candidate]
  K -- no --> NO[Discard]
```

The remaining 8 are now compared, for the first time, with the list of planted issues:

```python
inc = pd.read_csv("opciones3_incidencias.csv",
                  parse_dates=["fecha", "vencimiento"])
quedan = viol[viol["causa"] == "none"].merge(
    inc, on=["fecha", "vencimiento", "strike"], how="left")
quedan["semi"] = ((quedan["ask_c"] - quedan["bid_c"])
                  + (quedan["ask_p"] - quedan["bid_p"])) / 2
quedan["usd"] = 100 * quedan["reversal"]
quedan[["fecha", "vencimiento", "strike", "tipo", "incidencia",
        "reversal", "semi", "usd"]].round(
    {"reversal": 2, "semi": 2, "usd": 2})
```
```output
       fecha vencimiento  strike tipo       incidencia  reversal  semi      usd
0 2015-01-20  2015-04-17  2025.0    P  paridad_violada      1.51  0.50   150.90
1 2017-09-25  2017-12-15  2575.0    P  paridad_violada      1.79  0.55   178.68
2 2018-01-09  2018-03-16  2675.0    P  paridad_violada      1.50  0.50   150.00
3 2019-08-28  2019-11-15  2600.0    P  paridad_violada      1.77  0.60   176.54
4 2020-03-26  2020-06-19  2325.0    P  paridad_violada     11.85  3.90  1185.36
5 2022-09-26  2022-12-16  3100.0    P  paridad_violada      5.65  1.75   564.58
6 2024-10-04  2024-12-20  4950.0    P  paridad_violada      3.04  1.05   304.03
7 2024-10-07  2024-12-20  4950.0    P  paridad_violada      2.59  1.00   259.07
```

The eight are the `paridad_violada` ones on the list, and there are no others: the scanner finds all of them and does
not mistake anything else for them. All are a near-the-money put of the third expiry shifted upward, so all are
exploited with a reversal. The paper profit ranges from 1.50 to 11.85 points per pair, from 150 to 1,185 USD per
contract before costs; the largest, on March 26, 2020, in the middle of the crisis, when the spreads were also the
widest (half-sum of 3.90 points).

## Confirming with a box

The reversal requires selling the ORX forward, which does not exist. In practice one would use a future on the index,
with another expiry and other trading hours, and the trade would have basis risk. The **box** avoids the underlying:
with the suspicious strike as $K_1$ and the next one as $K_2$, buying the box (call bought and put sold at $K_1$, call
sold and put bought at $K_2$) costs $C_{ask}(K_1) - C_{bid}(K_2) + P_{ask}(K_2) - P_{bid}(K_1)$ and pays $K_2 - K_1$
for sure:

```python
ancho = ancho.sort_values(["fecha", "vencimiento", "strike"])
sig = ancho.groupby(["fecha", "vencimiento"]).shift(-1)
caja = ancho[["fecha", "vencimiento", "strike"]].assign(
    K2=sig["strike"],
    coste=(ancho["ask_c"] - sig["bid_c"]
           + sig["ask_p"] - ancho["bid_p"]),
    valor=ancho["DF"] * (sig["strike"] - ancho["strike"]))
caja["beneficio"] = caja["valor"] - caja["coste"]
claves = quedan[["fecha", "vencimiento", "strike"]]
claves.merge(caja, on=["fecha", "vencimiento", "strike"]).round(
    {"coste": 2, "valor": 2, "beneficio": 2})
```
```output
       fecha vencimiento  strike      K2  coste  valor  beneficio
0 2015-01-20  2015-04-17  2025.0  2050.0   23.9  25.00       1.10
1 2017-09-25  2017-12-15  2575.0  2600.0   23.6  24.94       1.34
2 2018-01-09  2018-03-16  2675.0  2700.0   23.9  24.94       1.04
3 2019-08-28  2019-11-15  2600.0  2625.0   23.7  24.89       1.19
4 2020-03-26  2020-06-19  2325.0  2350.0   16.9  25.00       8.10
5 2022-09-26  2022-12-16  3100.0  3150.0   45.9  49.66       3.76
6 2024-10-04  2024-12-20  4950.0  5000.0   47.5  49.47       1.97
7 2024-10-07  2024-12-20  4950.0  5000.0   47.8  49.49       1.69
```

The eight boxes confirm the violation with executable prices: the profit, with bid and ask on all four legs, ranges from
1.04 to 8.10 points. It is smaller than that of the reversal because the box pays four spreads instead of two plus the
forward. A box is, moreover, a fixed-rate loan: its price implies an interest rate that can be compared with that of
the curve (exercise 2).

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

A box or a reversal coming out positive at closing prices does not mean they could have been executed. Four legs are
needed at once (or a combo order), the closing prices of the chain and of the index must be simultaneous, and there
remain the commissions, the margin the box ties up until expiry and, for American options, the risk of early
assignment of the sold leg. With profits of 1 to 2 points, any of those details eats them up.

</aside>

## Slope and convexity across the whole chain

The Merton bounds are checked in the same way, with the execution condition on each leg: for monotonicity, buy the
lower-strike call at the ask and sell the higher-strike one at the bid; for the butterfly, buy the wings at the ask and
sell the body at the bid. The function takes the wide table and returns how many rows violate each bound; it is applied
to the whole chain and to the chain without the suspicious rows:

```python
def cotas(a):
    a = a.sort_values(["fecha", "vencimiento", "strike"])
    g = a.groupby(["fecha", "vencimiento"])
    s1, s2 = g.shift(-1), g.shift(-2)
    dk = s1["strike"] - a["strike"]
    lam = (s2["strike"] - s1["strike"]) / (s2["strike"]
                                           - a["strike"])
    DF, F, K = a["DF"], a["F"], a["strike"]
    return pd.Series({
        "C >= DF(F-K)+": (a["ask_c"] < DF * (F - K).clip(0)).sum(),
        "P >= DF(K-F)+": (a["ask_p"] < DF * (K - F).clip(0)).sum(),
        "C <= DF·F": (a["bid_c"] > DF * F).sum(),
        "P <= DF·K": (a["bid_p"] > DF * K).sum(),
        "C decreasing": (a["ask_c"] < s1["bid_c"]).sum(),
        "P increasing": (s1["ask_p"] < a["bid_p"]).sum(),
        "C slope": (a["bid_c"] - s1["ask_c"] > DF * dk).sum(),
        "P slope": (s1["bid_p"] - a["ask_p"] > DF * dk).sum(),
        "C butterfly": (lam * a["ask_c"] + (1 - lam) * s2["ask_c"]
                        < s1["bid_c"]).sum(),
        "P butterfly": (lam * a["ask_p"] + (1 - lam) * s2["ask_p"]
                        < s1["bid_p"]).sum(),
    })

ancho = ancho.join(marcas, on=["fecha", "vencimiento", "strike"])
sosp = ancho["congelada"] | ancho["cruzada"]
pd.DataFrame({"all": cotas(ancho),
              "without suspicious": cotas(ancho[~sosp])})
```
```output
               all  without suspicious
C >= DF(F-K)+    0                   0
P >= DF(K-F)+    0                   0
C <= DF·F        0                   0
P <= DF·K        0                   0
C decreasing   138                   0
P increasing   204                   1
C slope          4                   0
P slope          0                   0
C butterfly    278                   0
P butterfly    434                   8
```

With the whole chain, the level bounds (lower and upper) always hold, but there are hundreds of monotonicity and
convexity violations: a frozen quote from yesterday, after a down day, leaves a put cheaper than the lower-strike one or
a butterfly with a negative price. Without the suspicious rows only 9 violations remain, all on the puts: 8 butterflies
centered on the planted put (which is expensive relative to its neighbors) and one monotonicity violation, the one from
March 2020, where the 11.85-point shift leaves the 2,325 put above the 2,350 one. The Merton bounds and the parity flag
the same eight defects, which confirms that the true surface has no arbitrage and that everything else is data noise.

## American options: parity becomes an inequality

With early exercise, the parity replication is no longer exact: the American put can be exercised early and the call
can be exercised just before a dividend (lesson 1.2.5). What is left are two inequalities. For a stock with discrete
dividends of present value $D$ up to expiry:

$$
S - D - K \;\le\; C - P \;\le\; S - K\,e^{-rT}.
$$

The right-hand one comes from the American put being worth at least the European one; the left-hand one, from comparing
the call with a portfolio of put, cash $K$ and the stock. The data for **KRN**, a fictitious stock that pays a
quarterly dividend, comes with American options with their model price (binomial tree with discrete dividends, lesson
3.4.2) and that of the equivalent European option. With them, the European equality and the American inequalities are
checked:

```python
krn = pd.read_parquet("accion3_opciones.parquet")
acc = pd.read_csv("accion3_diario.csv", parse_dates=["fecha"],
                  index_col="fecha")
ev = pd.read_csv("accion3_eventos.csv", parse_dates=["fecha"])
divs = ev[ev["evento"] == "dividendo"].set_index("fecha")[
    "dividendo"]
k = krn.pivot_table(index=["fecha", "vencimiento", "strike"],
                    columns="tipo",
                    values=["precio_modelo", "europea_modelo",
                            "bid", "ask"]).dropna()
k.columns = [f"{c}_{t.lower()}" for c, t in k.columns]
k = k.reset_index()
k["T"] = (k["vencimiento"] - k["fecha"]).dt.days / 365
curva = ind.loc[k["fecha"], cols].to_numpy() / 100
k["r"] = [np.interp(t, plazos, c) for t, c in zip(k["T"], curva)]
k["S"] = acc.loc[k["fecha"], "cierre"].to_numpy()

def vp_dividendos(f, v, r):
    d = divs[(divs.index > f) & (divs.index <= v)]
    t = (d.index - f).days / 365
    return float((d * np.exp(-r * t)).sum())

u = k.drop_duplicates(["fecha", "vencimiento"])
vp = {(f, v): vp_dividendos(f, v, r)
      for f, v, r in zip(u["fecha"], u["vencimiento"], u["r"])}
k["D"] = [vp[x] for x in zip(k["fecha"], k["vencimiento"])]
k["KDF"] = k["strike"] * np.exp(-k["r"] * k["T"])

eu = (k["europea_modelo_c"] - k["europea_modelo_p"]
      - (k["S"] - k["D"] - k["KDF"]))
cp = k["precio_modelo_c"] - k["precio_modelo_p"]
tol = 0.0051                  # the close is rounded to the cent
dentro = cp.between(k["S"] - k["D"] - k["strike"] - tol,
                    k["S"] - k["KDF"] + tol)
print(f"{len(k)} pairs; European parity: max error "
      f"{eu.abs().max():.4f}")
print(f"American options within their bounds: {dentro.mean():.2%}")
```
```output
66763 pairs; European parity: max error 0.0051
American options within their bounds: 100.00%
```

The European options satisfy the equality with a maximum error of half a cent, the rounding of the stock's close. The
American ones, all of them, fall between their two bounds. The figure shows why they do not satisfy the equality: on a
day with a dividend ahead, $C - P$ of the American options moves away from the European line upward at the low strikes
(in-the-money calls, which would be exercised the day before the ex-date) and downward at the high ones (in-the-money
puts, which would be exercised to collect the strike earlier).

*Fig. — KRN American options between the parity bounds, 3 November 2025, 21 November expiry with a USD 1.04 dividend on the 13th.* By strike, from 145 to 225 USD, C − P of the American options minus European parity S − D − K·e^(−rT) (highlighted line), between the upper bound (+1.04, the dividend's present value) and the lower one (−K(1 − e^(−rT)), from −0.28 to −0.43). At low strikes it sits up to 0.88 USD above zero (in-the-money calls that would be exercised before the dividend); at high strikes, up to 0.19 below (in-the-money puts).

If the European equality is applied to the quotes of the American options, the scanner from the previous section finds
thousands of false arbitrages:

```python
teo_eu = k["S"] - k["D"] - k["KDF"]
conv = k["bid_c"] - k["ask_p"] - teo_eu
rev = teo_eu - (k["ask_c"] - k["bid_p"])
print("apparent conversions:", (conv > 0).sum())
print("apparent reversals:", (rev > 0).sum())
```
```output
apparent conversions: 1779
apparent reversals: 1006
```

That is 2,785 pairs out of 66,763, 4%, and none is real: the “conversion” sells an American call that the buyer will
exercise before the dividend, and the “reversal” sells an American put that will be assigned as soon as exercising it
pays off.

## What changes in real trading

- **Data first.** In options, an arbitrage scanner mostly finds errors: carried-forward prices, crossed quotes, call and
  put taken at different moments, a wrongly assigned expiry. The rules from the scanner section (frozen, crossed) are
  also the first cleaning step of an options backtest (lesson 3.6.4).
- **The forward, from the chain.** Using spot with an assumed dividend instead of the implied forward produces
  systematic violations at all strikes; the median of the parity near the money is robust and does not need to know the
  dividend.
- **Executable or nothing.** A deviation with mids within the half-sum of the spreads is not exploitable, however large
  it looks on a chart.
- **Exercise style.** With American options (stocks, ETFs, many options on futures) parity is an inequality and boxes
  carry assignment risk: the style of each contract has to be known before pricing it (lesson 3.4.2).
- **Bounds serve to clean surfaces.** Before fitting a smile (lesson 3.4.5) or computing a volatility index (3.4.6),
  the points that violate monotonicity or convexity are removed or corrected; otherwise, the fit inherits the
  arbitrage.

## Exercises

1. Repeat the parity scanner with the true forward and rate from `opciones3_verdad.parquet` instead of the estimated
   forward. Does the number of violations change? Why does it barely change?

**Solution.**

```python
con_verdad = ancho.merge(
    verdad[["fecha", "vencimiento", "forward", "tipo_libre"]],
    on=["fecha", "vencimiento"])
DFv = np.exp(-con_verdad["tipo_libre"] * con_verdad["T"])
teo_v = DFv * (con_verdad["forward"] - con_verdad["strike"])
conv_v = con_verdad["bid_c"] - con_verdad["ask_p"] - teo_v
rev_v = teo_v - (con_verdad["ask_c"] - con_verdad["bid_p"])
v2 = con_verdad[(conv_v > 0) | (rev_v > 0)]
print(len(v2), "violations with the truth;",
      len(viol), "with the estimated forward")
```
```output
345 violations with the truth; 346 with the estimated forward
```

With the truth there are 345 violations, one fewer than with the estimated forward. The error of the estimated forward
is almost always hundredths of a point, far below the half-sum of the spreads (from 0.5 to several points), so it only
changes the verdict for a pair that was right on the edge of its band. The median near the money is a good estimate of
the forward; what matters is that it does not get dragged by the defective strike.

2. Compute the implied rate of the boxes. With mid prices, $C - P = DF\,F - DF\,K$ is a straight line in the strike
   whose slope is $-DF$: fit that line every Wednesday on the second expiry, with the strikes within 10% of the forward,
   and deduce $r_{box} = -\ln(DF)/T$. Compare it with the curve rate. What would you expect in real data?

**Solution.**

```python
c2 = ancho.assign(
    medio_cp=(ancho["bid_c"] + ancho["ask_c"]
              - ancho["bid_p"] - ancho["ask_p"]) / 2)
c2 = c2[c2["fecha"].dt.weekday == 2]
orden = c2.groupby("fecha")["vencimiento"].rank(method="dense")
c2 = c2[(orden == 2) & ((c2["strike"] / c2["F"] - 1).abs() < 0.10)]
filas = []
for f, d in c2.groupby("fecha"):
    pendiente = np.polyfit(d["strike"], d["medio_cp"], 1)[0]
    T = d["T"].iloc[0]
    filas.append((f, -np.log(-pendiente) / T, d["r"].iloc[0],
                  len(d)))
cajas = pd.DataFrame(filas, columns=["fecha", "r_caja", "r", "n"])
dif = 1e4 * (cajas["r_caja"] - cajas["r"])
print(len(cajas), "Wednesdays; strikes per fit (median):",
      int(cajas["n"].median()))
print("difference (bp): median", round(dif.median(), 1),
      "| p5", round(dif.quantile(0.05), 1),
      "| p95", round(dif.quantile(0.95), 1))
```
```output
619 Wednesdays; strikes per fit (median): 15
difference (bp): median -0.6 | p5 -27.6 | p95 33.0
```

The slope of $C - P$ against the strike is a box with all the strikes at once: it is equivalent to averaging many boxes
and reduces the noise of each mid price. Even so, the implied rate moves tens of basis points around the curve rate:
with 30 to 60 days to expiry, an error of a few hundredths in the slope already amounts to many basis points of rate. A
box between two neighboring strikes would be much worse, because its error is divided by $K_2 - K_1$ (25 or 50 points)
instead of by the whole range of strikes. In the synthetic data there is no premium: the prices come from the curve.
With real data, van Binsbergen, Diamond and Grotteria (2022) obtain risk-free rates from the put-call parity of SPX
options (the “boxes”) and find that Treasury bills yield about 40 basis points less: the premium paid for the liquidity
and safety of government debt (*convenience yield*).

3. A colleague shows you a 6-point “conversion” on a 0.05-delta call the day after a 3% drop in the index, and proposes
   automating the search. Explain what you would check before sending an order.

**Solution.**

First, whether the call repeats the previous day's bid and ask: after a 3% drop, a far call with yesterday's price is
expensive only in the file, and it is the exact profile of the 330 frozen quotes in the lesson. Second, whether the bid
does not exceed the ask and whether the call and the put carry the same timestamp. Third, whether the violation survives
with the median estimated forward and with a box with the neighboring strike. Fourth, whether the profit covers the
commissions on four legs, the margin and the execution risk of a combo order. And, if the option is American, whether
the European equality even applies. Automating the search makes sense as a data quality control; as a strategy, only
with real-time prices and combo orders.

## Self-assessment

1. In a call-put pair, the parity deviation computed with mid prices is −4.09 points and the half-sum of the two bid-ask spreads is 1.05. What can be done?

   - A. Nothing: with mid prices it cannot be known whether there is arbitrage
   - B. Buy the call at the ask, sell the put at the bid and sell the forward: about 3 points more come in than the position is worth
   - C. Sell the call and buy the put, because the call is expensive
   - D. Wait for the deviation to exceed twice the spread

   *Answer B: Buy the call at the ask, sell the put at the bid and sell the forward: about 3 points more come in than the position is worth.* A deviation measured with mids larger (in absolute value) than the half-sum of the spreads is exactly equivalent to a trade executable at bid and ask. Here C − P is below its theoretical value: the put is expensive and the reversal (call bought, put sold, forward sold) earns about 3.04 points before costs, like the 4,950 put of 2024-10-04.

2. The parity scanner finds 346 executable violations in twelve years of chain data. What explains most of them?

   - A. Quotes that repeat the previous day's bid and ask in sessions in which the index moved more than 1%
   - B. Errors in the estimated forward
   - C. Real opportunities nobody took
   - D. The interest rate from the curve

   *Answer A: Quotes that repeat the previous day's bid and ask in sessions in which the index moved more than 1%.* 330 of the 346 have a frozen leg: the price is yesterday's and the index has moved. Another 8 have a crossed quote (bid above ask). Only 8 remain, which are exactly the violations planted in the data. An arbitrage scanner is, above all, a bad-data detector.

3. Why does a box spread serve to confirm a parity violation without needing to trade the underlying?

   - A. Because the box has no bid-ask spread
   - B. Because the box is an American option
   - C. Because it needs no margin
   - D. Because it combines two synthetics of the same expiry with different strikes and pays K2 − K1 in every scenario: its price depends only on the interest rate

   *Answer D: Because it combines two synthetics of the same expiry with different strikes and pays K2 − K1 in every scenario: its price depends only on the interest rate.* A bought call and sold put at K1 plus a sold call and bought put at K2 always pay K2 − K1 at expiry. It is worth the discounted value of that amount. If the put at one strike is expensive, the box that sells it costs less than that value: in the eight violations, the profit of the box at bid and ask ranges from 1.04 to 8.10 points.

4. In a clean chain, which of these relationships between calls of the same expiry can be violated without there being arbitrage?

   - A. C(K1) ≥ C(K2) if K1 < K2
   - B. C(K1) − C(K2) ≤ e^(−rT)·(K2 − K1)
   - C. None: all three are no-arbitrage bounds
   - D. The butterfly C(K1) − 2C(K2) + C(K3) ≥ 0 with equally spaced strikes

   *Answer C: None: all three are no-arbitrage bounds.* All three come from comparing portfolios: the lower-strike call dominates the higher-strike one; the difference between the two cannot pay more than K2 − K1; and the butterfly always pays something non-negative. In the ORX chain, after removing the suspicious quotes, only the planted puts violate them.

5. KRN has American options and pays a dividend before expiry. What does parity say?

   - A. S − D − K ≤ C − P ≤ S − K·e^(−rT), with D the present value of the dividend
   - B. C − P = S − D − K·e^(−rT), the same as for European options
   - C. C − P = S − K
   - D. There is no relationship between the American call and put

   *Answer A: S − D − K ≤ C − P ≤ S − K·e^(−rT), with D the present value of the dividend.* With early exercise, parity becomes two inequalities. In KRN, the 66,763 pairs of model prices fall inside (with half a cent of rounding), while applying the European equality to its American quotes would give 2,785 apparent arbitrages.

## Key points

- A bought call and a sold put at the same strike pay the same as a forward bought at that strike: hence the parity C − P = e^(−rT)(F − K) and, with two strikes, the fixed value of a box.
- The Merton bounds, monotonicity, slope and convexity in the strike are no-arbitrage conditions that do not depend on any model.
- A parity deviation measured with mids is executable if it exceeds the half-sum of the bid-ask spreads: it is the same condition as buying and selling at bid and ask.
- In twelve years of chain data there are 346 executable violations: 330 from frozen quotes, 8 from crossed quotes and 8 real ones, the planted ones, which a box confirms with 1.04 to 8.10 points of profit before costs.
- With American options and discrete dividends, parity is an inequality: applying the European equality produces thousands of false arbitrages.

## Resources

### Research

- [Uses of Volatility and Current Volatility - VIX1D](https://pythonparatrading.com/en/uses-of-volatility/) · Research-0003 · 5 min

### Documentation

- [Cboe: SPX option specifications (European, cash-settled)](https://www.cboe.com/tradable_products/sp_500/spx_options/specifications/) · cboe.com

### Books

- [Merton, R. C. (1973): Theory of Rational Option Pricing. The Bell Journal of Economics and Management Science 4(1), 141-183](https://doi.org/10.2307/3003143) · doi.org
- [Stoll, H. R. (1969): The Relationship Between Put and Call Option Prices. The Journal of Finance 24(5), 801-824](https://doi.org/10.1111/j.1540-6261.1969.tb01694.x) · doi.org
- [John C. Hull, Options, Futures, and Other Derivatives (Pearson, 11th ed., 2021)](https://www.pearson.com/en-us/subject-catalog/p/options-futures-and-other-derivatives/P200000005938) · pearson.com
- [van Binsbergen, J. H., Diamond, W. F. and Grotteria, M. (2022): Risk-free interest rates. Journal of Financial Economics 143(1), 1-29](https://doi.org/10.1016/j.jfineco.2021.06.012) · doi.org

### Data

- [Level 3 data: daily option chain on ORX, 2014-2025 (Parquet)](https://pythonparatrading.com/curso/datos/nivel-3/opciones3_cadena.parquet) · Download · opciones3_cadena.parquet
- [Level 3 data: issues planted in the chain (CSV)](https://pythonparatrading.com/curso/datos/nivel-3/opciones3_incidencias.csv) · Download · opciones3_incidencias.csv

Previous lesson: [3.3.3 · Currency risk in multi-currency portfolios; execution via OANDA, IBKR or MT5](https://pythonparatrading.com/en/python-trading-course/level-3/currency-risk-and-fx-execution/)

Next lesson: [3.4.2 · Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo](https://pythonparatrading.com/en/python-trading-course/level-3/binomial-black-scholes-black-76-monte-carlo/)
