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