6.5 KiB
Polymarket Arbitrage Scanner
A production-grade Python asyncio scanner that detects NegRisk multi-outcome
arbitrage on Polymarket, simulates fills in paper mode with realistic
latency penalties, and can sign + submit live orders through py-clob-client
behind hard risk gates.
Live browser demo · Project overview · Architecture
The arbitrage
Polymarket hosts categorical events (e.g. "Who wins the 2028 Election?") where every outcome trades as its own YES token. Because exactly one outcome must win, the fair prices across all outcomes must sum to $1. When the sum of best-asks across every outcome drops below $1, buying a complete set is a guaranteed $1 payout — a risk-free arbitrage.
Σ best_ask(outcome_i) < $1.00 ⟹ buy one of each, redeem for $1
The scanner watches the live CLOB, walks the order book depth to size each leg honestly, subtracts fees and amortised Polygon gas, and emits sized opportunities in real time.
Quickstart
git clone https://github.com/matthewnyc2/arbitrage
cd arbitrage
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
cp .env.example .env
arb init # create SQLite schema
arb discover # pull active negRisk events from Polymarket
arb scan & # start the paper scanner (WS + engine + executor)
arb web # dashboard at http://127.0.0.1:8000
Or with Docker
docker compose up
# dashboard at http://127.0.0.1:8000
What you're looking at
| Piece | File | Purpose |
|---|---|---|
| Gamma REST discovery | arbitrage/clients/polymarket_rest.py |
Paginates /events, filters to active negRisk categoricals, upserts to SQLite |
| WebSocket L2 book maintainer | arbitrage/clients/polymarket_ws.py + arbitrage/book/l2.py |
Subscribes to CLOB market channel, parses book / price_change events, maintains per-token sorted ladders with desync detection |
| Opportunity engine | arbitrage/engine/opportunity.py |
On every book tick, walks depth on every outcome, computes VWAP basket cost, picks the size that maximizes net expected profit after fees + gas |
| Paper executor | arbitrage/engine/paper_fills.py |
Simulates IOC fills at detection + latency_ms against the live book, writes baskets + fills to SQLite, marks PnL on resolution |
| Live executor | arbitrage/engine/live_executor.py |
Signs EIP-712 orders via py-clob-client, submits FAK in parallel across legs, unwinds partial fills, redeems on resolution. Gated behind ARB_MODE=live + risk caps |
| Risk gate | arbitrage/engine/live_executor.py::risk_gate |
Hard caps: basket USD, open-basket count (global + per-event), daily loss stop, kill-switch file |
| Dashboard | arbitrage/web/app.py + templates/ |
FastAPI + HTMX single page, auto-refreshing tables, one-click kill switch |
| CLI | arbitrage/cli.py |
arb init | discover | scan | web | resolve |
Architecture
Gamma REST CLOB WebSocket
│ │
▼ ▼
Event discovery L2 Book Maintainer
(active negRisk) (per token, in-memory)
│ │
└────────────┬───────────────┘
▼
Opportunity Engine
(depth-walk, fee-net, gas-amortized threshold)
│
▼
Risk Gate
(basket caps, daily loss, kill switch)
│
┌────────────┴───────────────┐
▼ ▼
Paper Executor Live Executor
(latency-penalized (sign + FAK + redeem)
sim fills + PnL) │
│ │
└────────────┬───────────────┘
▼
SQLite (WAL)
│
▼
FastAPI + HTMX dashboard
Modes
ARB_MODE=paper(default) — real data, simulated fills, no keys touched. Paper baskets sitpending_resolutionuntil the underlying market closes, then flip toredeemedorinvalidand realized PnL is booked.ARB_MODE=live— signs and submits real orders, redeems complete sets viaNegRiskAdapter.redeemPositions.LiveExecutor.dry_run=Trueby default so orders are logged rather than broadcast until an operator explicitly flips the flag.
Safety rails
| Cap | Env var | Default |
|---|---|---|
| Minimum net edge (bps) | ARB_MIN_NET_EDGE_BPS |
50 |
| Max USD per basket | ARB_MAX_BASKET_USD |
50 |
| Max open baskets (global) | ARB_MAX_OPEN_BASKETS |
3 |
| Max open baskets per event | hardcoded | 1 |
| Daily loss stop (USD) | ARB_DAILY_LOSS_STOP_USD |
100 |
| Kill switch file | ARB_KILL_SWITCH_FILE |
./KILL |
| Paper-mode latency penalty (ms) | ARB_PAPER_LATENCY_MS |
250 |
The dashboard has a red kill button that touches the kill-switch file; the executor refuses to open any new baskets while that file exists.
Test suite
pytest # 60 tests, ~5s
pytest --cov=arbitrage # with coverage
Coverage on the core math layers:
| Module | Coverage |
|---|---|
arbitrage/book/l2.py |
94% |
arbitrage/engine/paper_fills.py |
94% |
arbitrage/web/app.py |
92% |
arbitrage/db.py |
100% |
arbitrage/engine/opportunity.py |
81% |
Tech stack
Python 3.12 · asyncio · pydantic v2 · FastAPI + HTMX + Jinja2 · SQLite (WAL) ·
py-clob-client · web3.py · httpx · websockets · tenacity · loguru ·
pytest + pytest-asyncio · Docker
Status
Phase 1 (paper-workable) is complete — scanner runs end-to-end, all 60 tests
pass, CI green. Live executor skeleton is in place but gated. See
STATUS.html for an honest plain-English breakdown of what
works, what doesn't, and what it would take to run this in anger.
License
MIT. See LICENSE.