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 bands

  • tol_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.