Arbitrage from Option Prices — Single Expiry (Showcase)¶
This notebook demonstrates how to detect static arbitrage from option prices (mids) for a single expiration
We construct small synthetic chains to illustrate each classic arbitrage type:
Bounds (call/put upper & lower)
Monotonicity across strikes
Vertical spread upper cap
Convexity (butterfly)
Put–call parity
For each case, we build K, C, P, F, D, call the detector, and print any trade recipes.
import numpy as np
from volkit.arb.prices_single_expiry import arb_from_option_prices_single_expiry
Common parameters¶
We’ll use a simple setup with a known future F and discount factor D.
Tolerances are split as:
tol_opt/tol_opt_rel— option-side absolute and relative bandstol_fut/tol_fut_rel— future-side absolute and relative bands
For synthetic demos we set them small (but not zero) to avoid flagging near-boundary floating-point noise.
F = 100.0
D = 0.99
tol_opt = 1e-8
tol_opt_rel = 0.0
tol_fut = 0.0
tol_fut_rel = 0.0
1) Bounds — Calls (lower & upper)¶
Lower bound (ITM):
C >= D * max(F - K, 0)Upper bound:
C <= D * F
We will:
Make one ITM call too cheap (violates lower bound).
Make one call too expensive (violates upper bound).
K = np.array([80, 90, 100, 110, 120], dtype=float)
# Start from a sane decreasing call curve (toy numbers)
C = np.array([22.0, 14.5, 8.0, 4.0, 2.0], dtype=float)
P = np.array([ 0.5, 1.5, 4.0, 9.0, 17.0], dtype=float)
# Violate call lower bound at K=90 (ITM): intrinsic = D*(F-K)=0.99*10=9.9; set C<9.9
C[1] = 8.0 # too low
# Violate call upper bound at K=80: DF=99.0; set C>99
C[0] = 100.5 # too high
report = arb_from_option_prices_single_expiry(
K, C=C, P=P, F=F, D=D,
tol_opt=tol_opt, tol_opt_rel=tol_opt_rel,
tol_fut=tol_fut, tol_fut_rel=tol_fut_rel,
check_bounds=True, check_monotonicity=False, check_vertical=False,
check_convexity=False, check_parity=False, assume_sorted=True,
as_report=True
)
#show_trades(report, "Bounds — Calls (lower & upper)")
print(report)
#1 bounds: Call costs more than the forward (option overpriced).
Asset Side Qty Strike Unit Price Notional Cashflow today
call sell 1.0 80.0 100.5 100.5
bond buy 1.0 99.0 100.0 -99.0
TOTAL 100.0 1.5
Entry cash: +1.500000 (recieve today)
#2 bounds: Call priced below intrinsic value; too cheap vs forward.
Asset Side Qty Strike Unit Price Notional Cashflow today
call buy 1.0 90.0 8.0 -8.0
bond sell 1.0 9.9 10.0 9.9
TOTAL 10.0 1.9
Entry cash: +1.900000 (recieve today)
#3 bounds: Put priced below intrinsic value; too cheap vs forward.
Asset Side Qty Strike Unit Price Notional Cashflow today
put buy 1.0 120.0 17.0 -17.0
bond sell 1.0 19.8 20.0 19.8
TOTAL 20.0 2.8
Entry cash: +2.800000 (recieve today)
2) Bounds — Puts (lower & upper)¶
Lower bound (ITM):
P >= D * max(K - F, 0)Upper bound:
P <= D * K
K = np.array([80, 90, 100, 110, 120], dtype=float)
# Reasonable put curve (toy)
P = np.array([0.2, 0.7, 3.5, 8.5, 16.5], dtype=float)
C = np.array([22.0, 14.5, 8.0, 4.0, 2.0], dtype=float)
# Violate put lower bound at K=120: intrinsic = D*(120-100)=19.8; set P<19.8
P[-1] = 18.0 # too low
# Violate put upper bound at K=110: DK=0.99*110=108.9; set P>108.9
P[3] = 110.0 # too high
report = arb_from_option_prices_single_expiry(
K, C=C, P=P, F=F, D=D,
tol_opt=tol_opt, tol_opt_rel=tol_opt_rel,
tol_fut=tol_fut, tol_fut_rel=tol_fut_rel,
check_bounds=True, check_monotonicity=False, check_vertical=False,
check_convexity=False, check_parity=False, assume_sorted=True,
as_report=True
)
print(report)
#1 bounds: Put costs more than the discounted strike (overpriced).
Asset Side Qty Strike Unit Price Notional Cashflow today
put sell 1.0 110.0 110.0 110.0
bond buy 1.0 108.9 110.0 -108.9
TOTAL 110.0 1.1
Entry cash: +1.100000 (recieve today)
#2 bounds: Put priced below intrinsic value; too cheap vs forward.
Asset Side Qty Strike Unit Price Notional Cashflow today
put buy 1.0 120.0 18.0 -18.0
bond sell 1.0 19.8 20.0 19.8
TOTAL 20.0 1.8
Entry cash: +1.800000 (recieve today)
3) Monotonicity — Calls increasing in strike¶
Calls must be non-increasing in strike. We’ll make C(K=100) < C(K=110) to trigger a negative-cost call spread.
K = np.array([90, 100, 110], dtype=float)
C = np.array([12.0, 8.0, 9.5], dtype=float) # 9.5 > 8.0 → violation
P = np.array([ 1.0, 3.0, 8.0], dtype=float)
report = arb_from_option_prices_single_expiry(
K, C=C, P=P, F=F, D=D,
tol_opt=tol_opt, tol_opt_rel=tol_opt_rel,
tol_fut=tol_fut, tol_fut_rel=tol_fut_rel,
check_bounds=False, check_monotonicity=True, check_vertical=False,
check_convexity=False, check_parity=False, assume_sorted=True,
as_report=True
)
print(report)
#1 monotonicity: Higher-strike call priced above a lower-strike call (should be cheaper).
Asset Side Qty Strike Unit Price Notional Cashflow today
call buy 1.0 100.0 8.0 -8.0
call sell 1.0 110.0 9.5 9.5
TOTAL 1.5
Entry cash: +1.500000 (recieve today)
4) Vertical spread upper cap — Calls¶
For adjacent strikes, C(K_i) - C(K_{i+1}) <= D * ΔK. We’ll set the spread too wide.
K = np.array([95, 100], dtype=float)
dK = K[1] - K[0]
cap = D * dK
# Reasonable values, but push C[0] - C[1] above cap
C = np.array([8.0 + cap + 0.50, 1.0], dtype=float) # spread exceeds cap by 0.5
P = np.array([2.0, 4.0], dtype=float)
report = arb_from_option_prices_single_expiry(
K, C=C, P=P, F=F, D=D,
tol_opt=1e-6, tol_opt_rel=0.0,
tol_fut=0.0, tol_fut_rel=0.0,
check_bounds=False, check_monotonicity=False, check_vertical=True,
check_convexity=False, check_parity=False, assume_sorted=True,
as_report=True
)
print(report)
#1 vertical: Call spread priced above its maximum possible payoff.
Asset Side Qty Strike Unit Price Notional Cashflow today
call sell 1.0 95.0 13.45 13.45
call buy 1.0 100.0 1.0 -1.00
bond buy 1.0 4.95 5.0 -4.95
TOTAL 5.0 7.50
Entry cash: +7.500000 (recieve today)
5) Convexity — Butterfly (calls)¶
For K0 < K1 < K2 with λ = (K2 - K1) / (K2 - K0), convexity requires:
C1 <= λ*C0 + (1-λ)*C2
We’ll set C1 too large to trigger a butterfly buy (credit).
K = np.array([90, 100, 120], dtype=float)
lam = (K[2] - K[1]) / (K[2] - K[0]) # (120-100)/(120-90)=20/30=2/3
# Baseline convex combination RHS
C0, C2 = 18.0, 5.0
rhs = lam*C0 + (1-lam)*C2 # expected upper bound for C1
# Violate by lifting C1 above rhs
C1 = rhs + 0.75
C = np.array([C0, C1, C2], dtype=float)
P = np.array([1.0, 3.0, 12.0], dtype=float)
report = arb_from_option_prices_single_expiry(
K, C=C, P=P, F=F, D=D,
tol_opt=1e-6, tol_opt_rel=0.0,
tol_fut=0.0, tol_fut_rel=0.0,
check_bounds=False, check_monotonicity=False, check_vertical=False,
check_convexity=True, check_parity=False, assume_sorted=True,
as_report=True
)
print(report)
#1 convexity: Middle-strike call overpriced relative to nearby strikes (curve not convex).
Asset Side Qty Strike Unit Price Notional Cashflow today
call buy 0.666667 90.0 18.0 -12.000000
call buy 0.333333 120.0 5.0 -1.666667
call sell 1.0 100.0 14.416667 14.416667
TOTAL 0.750000
Entry cash: +0.750000 (recieve today)
6) Put–call parity — Call-rich and Put-rich¶
Parity requires C - P = D*(F - K) per strike.
We’ll create one call-rich and one put-rich violation.
K = np.array([95, 105], dtype=float)
# Start from parity-satisfying base
# Choose prices so that C - P = D*(F-K)
def make_parity_prices(Ki):
rhs = D * (F - Ki)
# pick a base C, then P = C - rhs
Cb = 6.0
Pb = Cb - rhs
return Cb, Pb
C0, P0 = make_parity_prices(K[0])
C1, P1 = make_parity_prices(K[1])
C = np.array([C0, C1], dtype=float)
P = np.array([P0, P1], dtype=float)
# Introduce violations
C[0] += 0.75 # call-rich at K=95 (m > 0)
P[1] += 1.00 # put-rich at K=105 (m < 0)
report = arb_from_option_prices_single_expiry(
K, C=C, P=P, F=F, D=D,
tol_opt=1e-6, tol_opt_rel=0.0,
tol_fut=0.0, tol_fut_rel=0.0,
check_bounds=False, check_monotonicity=False, check_vertical=False,
check_convexity=False, check_parity=True, assume_sorted=True,
as_report=True
)
print(report)
#1 parity: Call too expensive relative to same-strike put (put–call parity broken).
Asset Side Qty Strike Unit Price Notional Cashflow today
call sell 1.0 95.0 6.75 6.75
put buy 1.0 95.0 1.05 -1.05
bond buy 1.0 4.95 5.0 -4.95
TOTAL 5.0 0.75
Entry cash: +0.750000 (recieve today)
#2 parity: Put too expensive relative to same-strike call (put–call parity broken).
Asset Side Qty Strike Unit Price Notional Cashflow today
call buy 1.0 105.0 6.0 -6.00
put sell 1.0 105.0 11.95 11.95
bond sell 1.0 -4.95 -5.0 -4.95
TOTAL -5.0 1.00
Entry cash: +1.000000 (recieve today)
7) Mixed case — Multiple issues in one chain¶
We combine several violations to see multiple trade recipes at once.
K = np.array([90, 95, 100, 105], dtype=float)
C = np.array([15.0, 10.0, 8.0, 8.5], dtype=float) # monotonicity violation at (100,105)
P = np.array([ 1.0, 2.0, 4.0, 9.0], dtype=float)
# Push a vertical cap violation between 90 and 95
dK = K[1] - K[0]
cap = D * dK
C[0] = C[1] + cap + 0.4
# Make a parity violation at K=100
rhs = D * (F - K[2])
# C[2] - P[2] should equal rhs; we'll add +0.6 to make it call-rich
C[2] = P[2] + rhs + 0.6
report = arb_from_option_prices_single_expiry(
K, C=C, P=P, F=F, D=D,
tol_opt=1e-6, tol_opt_rel=0.0,
tol_fut=0.0, tol_fut_rel=0.0,
check_bounds=True, check_monotonicity=True, check_vertical=True,
check_convexity=True, check_parity=True, assume_sorted=True,
as_report=True
)
print(report)
#1 vertical: Call spread priced above its maximum possible payoff.
Asset Side Qty Strike Unit Price Notional Cashflow today
call sell 1.0 90.0 15.35 15.35
call buy 1.0 95.0 10.0 -10.00
bond buy 1.0 4.95 5.0 -4.95
TOTAL 5.0 0.40
Entry cash: +0.400000 (recieve today)
#2 vertical: Call spread priced above its maximum possible payoff.
Asset Side Qty Strike Unit Price Notional Cashflow today
call sell 1.0 95.0 10.0 10.00
call buy 1.0 100.0 4.6 -4.60
bond buy 1.0 4.95 5.0 -4.95
TOTAL 5.0 0.45
Entry cash: +0.450000 (recieve today)
#3 monotonicity: Higher-strike call priced above a lower-strike call (should be cheaper).
Asset Side Qty Strike Unit Price Notional Cashflow today
call buy 1.0 100.0 4.6 -4.6
call sell 1.0 105.0 8.5 8.5
TOTAL 3.9
Entry cash: +3.900000 (recieve today)
#4 vertical: Put spread priced above its maximum possible payoff.
Asset Side Qty Strike Unit Price Notional Cashflow today
put sell 1.0 105.0 9.0 9.00
put buy 1.0 100.0 4.0 -4.00
bond buy 1.0 4.95 5.0 -4.95
TOTAL 5.0 0.05
Entry cash: +0.050000 (recieve today)
#5 convexity: Middle-strike call overpriced relative to nearby strikes (curve not convex).
Asset Side Qty Strike Unit Price Notional Cashflow today
call buy 0.5 90.0 15.35 -7.675
call buy 0.5 100.0 4.6 -2.300
call sell 1.0 95.0 10.0 10.000
TOTAL 0.025
Entry cash: +0.025000 (recieve today)
#6 parity: Call too expensive relative to same-strike put (put–call parity broken).
Asset Side Qty Strike Unit Price Notional Cashflow today
call sell 1.0 90.0 15.35 15.35
put buy 1.0 90.0 1.0 -1.00
bond buy 1.0 9.9 10.0 -9.90
TOTAL 10.0 4.45
Entry cash: +4.450000 (recieve today)
#7 parity: Call too expensive relative to same-strike put (put–call parity broken).
Asset Side Qty Strike Unit Price Notional Cashflow today
call sell 1.0 95.0 10.0 10.00
put buy 1.0 95.0 2.0 -2.00
bond buy 1.0 4.95 5.0 -4.95
TOTAL 5.0 3.05
Entry cash: +3.050000 (recieve today)
#8 parity: Call too expensive relative to same-strike put (put–call parity broken).
Asset Side Qty Strike Unit Price Notional Cashflow today
call sell 1.0 100.0 4.6 4.6
put buy 1.0 100.0 4.0 -4.0
bond buy 1.0 0.0 0.0 -0.0
TOTAL 0.6
Entry cash: +0.600000 (recieve today)
#9 parity: Call too expensive relative to same-strike put (put–call parity broken).
Asset Side Qty Strike Unit Price Notional Cashflow today
call sell 1.0 105.0 8.5 8.50
put buy 1.0 105.0 9.0 -9.00
bond buy 1.0 -4.95 -5.0 4.95
TOTAL -5.0 4.45
Entry cash: +4.450000 (recieve today)
Notes¶
These are price-only signals using mids. The leg lists are theoretical until you evaluate them with quotes (bid/ask) and check worst-case P&L (after tick/fees/borrow).
Tolerances (
tol_opt,tol_opt_rel,tol_fut,tol_fut_rel) help avoid microstructure noise flags. Tune them to your venue/instrument.The same portfolio structures appear in the quote-aware detector; only the pricing of legs changes (bid/ask rather than mids), plus feasibility filters.