Lesson 3.4.1 · 65 min

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.

Fig. — Put-call parity deviation by strike, ORX, 4 October 2024, December expiry (77 days)
01/07
Module
3.4 · Option theory and pricing
Duration
65 min
Updated
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)

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:

PortfolioPays at expiryName
Call bought at K + put sold at KST−KS_T - Klong synthetic forward
Call bought at K1 − call sold at K2between 0 and K2 − K1call spread (vertical)
Synthetic bought at K1 + synthetic sold at K2K2−K1K_2 - K_1 alwaysbox spread
Call at K1 − 2 calls at K2 + call at K3triangle, never negativebutterfly
Call spread of width ΔK divided by ΔK≈ 1 if ST>KS_T > Kdigital (approximate)
Call boughtPortfolio APut soldPays S − KForward at KSame price today
Mermaid source
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 ST−KS_T - K in every scenario. A forward at K costs today the present value of the difference between the market forward FF and KK, so:

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

With two strikes, the difference of two synthetic forwards is the box: it pays K2−K1K_2 - K_1 whatever happens and is worth e−rT(K2−K1)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:

IN [01]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])])
OUT [01]Payoffs as sums of pieces
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 FF, discount factor DF=e−rTDF = e^{-rT} and strikes K1<K2<K3K_1 < K_2 < K_3:

ConditionFormulaWhy
Call lower boundC≥DF (F−K)+C \ge DF\,(F - K)^+the call pays at least what the synthetic forward does, and never less than 0
Call upper boundC≤DF F=S e−qTC \le DF\,F = S\,e^{-qT}it never pays more than the index
Put boundsDF (K−F)+≤P≤DF KDF\,(K - F)^+ \le P \le DF\,Klikewise; it never pays more than the strike
MonotonicityC(K1)≥C(K2)C(K_1) \ge C(K_2), P(K1)≤P(K2)P(K_1) \le P(K_2)the lower-strike call always pays at least as much
SlopeC(K1)−C(K2)≤DF (K2−K1)C(K_1) - C(K_2) \le DF\,(K_2 - K_1)the call spread pays at most K2−K1K_2 - K_1
ConvexityλC(K1)+(1−λ)C(K3)≥C(K2)\lambda C(K_1) + (1-\lambda) C(K_3) \ge C(K_2)the butterfly pays something non-negative; λ=K3−K2K3−K1\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.

IN [02]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)
OUT [02]The chain in wide format
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 rr and FF 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 FK=K+(C−P)/DFF_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:

IN [03]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}")
OUT [03]Curve rate and implied forward
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 FF and DFDF, 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 Cbid−Pask−DF (F−K)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)−(Cask−Pbid)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: Cbid−PaskC_{bid} - P_{ask} is the mid of C−PC - P minus that half-sum. The cell checks it on the 272,274 pairs:

IN [04]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())
OUT [04]Apparent arbitrages in twelve years
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:

IN [05]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()
OUT [05]Bad data or arbitrage?
{'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
yesnoyesnonoyesyesnoViolationCrossed?Bad dataFrozen?Box confirms?Check F and rCovers costs?CandidateDiscard
Mermaid source
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:

IN [06]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})
OUT [06]The eight that remain
       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 K1K_1 and the next one as K2K_2, buying the box (call bought and put sold at K1K_1, call sold and put bought at K2K_2) costs Cask(K1)−Cbid(K2)+Pask(K2)−Pbid(K1)C_{ask}(K_1) - C_{bid}(K_2) + P_{ask}(K_2) - P_{bid}(K_1) and pays K2−K1K_2 - K_1 for sure:

IN [07]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})
OUT [07]Boxes on the eight violations
       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).

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:

IN [08]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])})
OUT [08]No-arbitrage bounds, with and without suspicious data
               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 DD up to expiry:

S−D−K  ≤  C−P  ≤  S−K e−rT.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 KK 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:

IN [09]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%}")
OUT [09]KRN: European parity versus American bounds
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−PC - 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

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

IN [10]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())
OUT [10]The European equality applied to American options
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
IN [11]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")
OUT [11]Scanner with the true forward
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.

  1. Compute the implied rate of the boxes. With mid prices, C−P=DF F−DF KC - P = DF\,F - DF\,K is a straight line in the strike whose slope is −DF-DF: fit that line every Wednesday on the second expiry, with the strikes within 10% of the forward, and deduce rbox=−ln⁡(DF)/Tr_{box} = -\ln(DF)/T. Compare it with the curve rate. What would you expect in real data?
Solution
IN [12]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))
OUT [12]Implied rate of the boxes
619 Wednesdays; strikes per fit (median): 15
difference (bp): median -0.6 | p5 -27.6 | p95 33.0

The slope of C−PC - 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 K2−K1K_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).

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

Each answer is folded under its question

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?

    • ANothing: with mid prices it cannot be known whether there is arbitrage
    • BBuy 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
    • CSell the call and buy the put, because the call is expensive
    • DWait for the deviation to exceed twice the spread
    Show answer

    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?

    • AQuotes that repeat the previous day's bid and ask in sessions in which the index moved more than 1%
    • BErrors in the estimated forward
    • CReal opportunities nobody took
    • DThe interest rate from the curve
    Show answer

    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?

    • ABecause the box has no bid-ask spread
    • BBecause the box is an American option
    • CBecause it needs no margin
    • DBecause 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
    Show answer

    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?

    • AC(K1) ≥ C(K2) if K1 < K2
    • BC(K1) − C(K2) ≤ e^(−rT)·(K2 − K1)
    • CNone: all three are no-arbitrage bounds
    • DThe butterfly C(K1) − 2C(K2) + C(K3) ≥ 0 with equally spaced strikes
    Show answer

    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?

    • AS − D − K ≤ C − P ≤ S − K·e^(−rT), with D the present value of the dividend
    • BC − P = S − D − K·e^(−rT), the same as for European options
    • CC − P = S − K
    • DThere is no relationship between the American call and put
    Show answer

    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

Data