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.
- Module
- 3.4 · Option theory and pricing
- Duration
- 65 min
- Prerequisites
- 1.2.5 · Options: calls, puts, intrinsic value and time value, 1.4.2 · Data cleaning: missing values, bad ticks, gaps and time zones
- Author
- Jesús Cuesta
- Updated
Level 3 lessons
3.1 · Futures in depth
3.2 · Futures spreads
- 3.2.1 Calendar spreads, butterflies and condors
- 3.2.2 Intercommodity spreads: crack, spark and crush
- 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
- 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
- 3.2.5 Spread data and backtesting: listed, synthetic and legged
- 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
3.3 · Advanced forex
3.4 · Option theory and pricing
- 3.4.1 Payoffs, put-call parity and arbitrage bounds
- 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
- 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
- 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
- 3.4.5 Smile, skew and term structure; volatility surface with SVI
- 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
- 3.4.7 Advanced models: Heston, SABR and local volatility
3.5 · Option structures in Python
- 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
- 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
- 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
- 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
- 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
3.6 · Systematic volatility and options strategies
3.7 · Crypto derivatives and specific strategies
- 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
- 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
- 3.7.3 Crypto options, on-chain data and DeFi
- 3.7.4 ccxt for trading on multiple exchanges
- 3.7.5 MiCA: the EU framework for crypto-assets and their service providers (CASPs)
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 | 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 | 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 | digital (approximate) |
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 in every scenario. A forward at K costs today the present value of the difference between the market forward and , so:
With two strikes, the difference of two synthetic forwards is the box: it pays whatever happens and is worth . 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:
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])])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 , discount factor and strikes :
| Condition | Formula | Why |
|---|---|---|
| Call lower bound | the call pays at least what the synthetic forward does, and never less than 0 | |
| Call upper bound | it never pays more than the index | |
| Put bounds | likewise; it never pays more than the strike | |
| Monotonicity | , | the lower-strike call always pays at least as much |
| Slope | the call spread pays at most | |
| Convexity | the butterfly pays something non-negative; |
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.
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)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.20Checking parity requires and 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 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:
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}")9051 slices; forward error: median 0.000, p99 0.181, max 0.457 points
Maximum rate error: 4.9e-07Across 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 and , 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 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 .
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: is the mid of minus that half-sum. The cell checks it on the 272,274 pairs:
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())346 pairs with apparent arbitrage out of 272274
The same count with mids: 346346 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:
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(){'congelada': 11336, 'cruzada': 12}
causa
frozen quote 330
none 8
crossed quote 8
Name: count, dtype: int64The 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.
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:
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}) 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.07The 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 and the next one as , buying the box (call bought and put sold at , call sold and put bought at ) costs and pays for sure:
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}) 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.69The 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:
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])}) 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 8With 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 up to expiry:
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 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:
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%}")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, 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).
If the European equality is applied to the quotes of the American options, the scanner from the previous section finds thousands of false arbitrages:
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())apparent conversions: 1779
apparent reversals: 1006That 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
- Repeat the parity scanner with the true forward and rate from
opciones3_verdad.parquetinstead of the estimated forward. Does the number of violations change? Why does it barely change?
Solution
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")345 violations with the truth; 346 with the estimated forwardWith 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.
- Compute the implied rate of the boxes. With mid prices, is a straight line in the strike whose slope is : fit that line every Wednesday on the second expiry, with the strikes within 10% of the forward, and deduce . Compare it with the curve rate. What would you expect in real data?
Solution
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))619 Wednesdays; strikes per fit (median): 15
difference (bp): median -0.6 | p5 -27.6 | p95 33.0The slope of 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 (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).
- 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
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.
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.
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.
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.
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
Books
- Merton, R. C. (1973): Theory of Rational Option Pricing. The Bell Journal of Economics and Management Science 4(1), 141-183
- Stoll, H. R. (1969): The Relationship Between Put and Call Option Prices. The Journal of Finance 24(5), 801-824
- John C. Hull, Options, Futures, and Other Derivatives (Pearson, 11th ed., 2021)
- van Binsbergen, J. H., Diamond, W. F. and Grotteria, M. (2022): Risk-free interest rates. Journal of Financial Economics 143(1), 1-29