152 lines
7.8 KiB
Markdown
152 lines
7.8 KiB
Markdown
# Polymarket Arbitrage Bot — Design
|
||
|
||
## Goal
|
||
|
||
Detect and (eventually) capture **NegRisk multi-outcome arbitrage** on Polymarket: cases where the sum of best-ask prices across all outcomes of a categorical event is less than $1, allowing a complete-set buy + redeem for a guaranteed $1 payout.
|
||
|
||
Two modes from day one:
|
||
|
||
- **Paper** — runs end-to-end against live data, simulates fills, tracks PnL through real on-chain resolutions. No private keys. No capital at risk.
|
||
- **Live** — signs and submits real orders via `py-clob-client`, hard-capped by basket size, open-basket count, and daily loss.
|
||
|
||
A single env var (`ARB_MODE`) flips between them. The opportunity scanner, book maintainer, and risk gates are identical in both modes.
|
||
|
||
## The arbitrage
|
||
|
||
For a Polymarket negRisk event with outcomes O₁..Oₙ, each outcome trades as its own ERC-1155 token. If outcome Oᵢ wins, holders of Oᵢ receive $1 per share; all other outcomes pay $0. Exactly one outcome wins, so:
|
||
|
||
```
|
||
Σ fair_price(Oᵢ) = $1
|
||
```
|
||
|
||
When `Σ best_ask(Oᵢ) < $1`, buying one share of every outcome costs less than the guaranteed $1 payout. The basket is risk-free (subject to resolution risk — see below) and self-redeems via `NegRiskAdapter.redeemPositions`.
|
||
|
||
**Net edge per basket**:
|
||
```
|
||
net_edge = $1
|
||
- Σ vwap_ask(Oᵢ, size)
|
||
- Σ taker_fee(Oᵢ)
|
||
- amortized_gas(per_basket)
|
||
- safety_margin
|
||
```
|
||
|
||
## Architecture
|
||
|
||
```
|
||
Gamma REST CLOB WebSocket
|
||
│ │
|
||
▼ ▼
|
||
Event/Outcome L2 Book Maintainer
|
||
discovery (per token_id, in-mem)
|
||
│ │
|
||
└────────────┬───────────────┘
|
||
▼
|
||
Opportunity Engine
|
||
(Σ best-asks, depth-walk, fee-net, threshold)
|
||
│
|
||
▼
|
||
Risk Gate (limits)
|
||
│
|
||
┌────────────┴───────────────┐
|
||
▼ ▼
|
||
Paper Executor Live Executor
|
||
(sim fills + PnL) (sign + IOC + redeem)
|
||
│ │
|
||
└────────────┬───────────────┘
|
||
▼
|
||
SQLite (WAL)
|
||
│
|
||
▼
|
||
FastAPI + HTMX dashboard
|
||
```
|
||
|
||
## Why this stack
|
||
|
||
- **Python 3.12 + asyncio** — official `py-clob-client` is Python; mature `web3.py`; fewer moving parts than a JS toolchain.
|
||
- **FastAPI + HTMX + Jinja2** — single HTML page, no SPA build step, no client-side state.
|
||
- **SQLite (WAL)** — single-process app, single user; zero ops. Upgrade path to Postgres if multi-process ever happens.
|
||
- **pydantic v2** — typed contracts at every boundary.
|
||
- **loguru + structured JSON logs** — searchable post-hoc.
|
||
- **pytest + pytest-asyncio + respx** — book/math/sim layers fully unit-tested with mocked HTTP.
|
||
|
||
## Data model
|
||
|
||
| Entity | Key | Notes |
|
||
|---|---|---|
|
||
| `Event` | `id` | `negRiskMarketID` for negRisk events; `condition_id` for vanilla. Holds 2..N outcomes. |
|
||
| `Outcome` | `token_id` | ERC-1155 token id (CTF positionId). One per outcome per event. |
|
||
| `Book` | `(token_id)` in-mem | Sorted L2; replaced on snapshot, mutated on `price_change`. |
|
||
| `Opportunity` | `uuid` | Snapshot of legs + sizing + edge math. Persisted whether acted on or not. |
|
||
| `Basket` | `uuid` | A set of paper or live fills intended to redeem to $1. |
|
||
| `Fill` | autoinc | Per-leg fill within a basket. |
|
||
| `Resolution` | `event_id` | Winning outcome token id when oracle resolves. Drives basket redemption + paper PnL. |
|
||
|
||
## The hard problems (and how we mitigate)
|
||
|
||
### 1. Executable edge ≠ quoted edge
|
||
The opportunity engine **walks the book on every leg** and computes VWAP for the chosen basket size. The reported `net_edge` and `max_baskets` are clipped by the thinnest leg's depth. Top-of-book is never trusted on its own.
|
||
|
||
### 2. Multi-leg fill races (live mode)
|
||
Strategy: submit all N leg orders in parallel as **FAK (fill-and-kill)**. If any leg partial-fills, immediately try to unwind the over-filled legs at market. Persist every attempt for forensics. The paper executor models this by simulating the same FAK semantics against the observed book.
|
||
|
||
### 3. Fees, gas, and the "amortized gas trap"
|
||
Gas to submit N orders + 1 redeem dominates edge on small baskets. The opportunity engine computes `min_basket_count` such that amortized gas < edge, and refuses to fire below that.
|
||
|
||
### 4. Resolution risk (UMA disputes)
|
||
A "guaranteed" basket can pay $0 if the market resolves "invalid" or gets adversarially redirected. Mitigations:
|
||
- Skip events within 24h of `end_date` (price action there is dominated by resolution-game theory).
|
||
- Per-event exposure cap (`MAX_BASKET_USD` × `MAX_OPEN_BASKETS_PER_EVENT=1`).
|
||
- Persist a denylist of past disputed/invalid markets.
|
||
|
||
### 5. Paper-mode realism
|
||
Naive paper trading assumes you fill at the book at observation time. Real bots get beaten by faster ones. The simulator delays our hypothetical order arrival by `ARB_PAPER_LATENCY_MS` (default 250ms): we only fill against price levels that survived for that latency window, modeling the front-run penalty. This biases paper PnL **down**, which is the safe direction.
|
||
|
||
## Lifecycle: paper basket
|
||
|
||
1. Engine emits `Opportunity{event_id, legs[], net_edge_bps, max_baskets}`
|
||
2. Risk gate accepts (under all caps, edge ≥ threshold)
|
||
3. Paper executor creates `paper_baskets` row with status=`open`
|
||
4. For each leg: walk the book at `t = detected_at + paper_latency_ms`, compute fills, write `paper_fills` rows
|
||
5. If any leg lacks depth at the latency-shifted moment → status=`failed`, no PnL impact (this is the "missed it" case)
|
||
6. Otherwise status=`pending_resolution`, accumulate `total_cost_usd`
|
||
7. Resolution watcher polls Gamma `/events?id=...` for `closed=true`. On resolve: status=`redeemed`, `realized_pnl_usd = (1 * basket_count) - total_cost_usd` (or `-total_cost_usd` if invalid)
|
||
|
||
## Lifecycle: live basket
|
||
|
||
Identical to paper through step 2. Then:
|
||
|
||
3. Live executor creates `live_orders` rows for each leg with status=`pending`
|
||
4. Submit all N orders in parallel (FAK) via `py-clob-client`
|
||
5. Poll fills (or use `user` WS channel)
|
||
6. If complete-set held → call `NegRiskAdapter.redeemPositions(...)` with current gas estimate; record tx hash
|
||
7. If partial → submit market unwind orders for the over-filled legs; record net cost
|
||
8. Resolution watcher mirrors paper for accurate post-mortem PnL on un-redeemed legacy baskets
|
||
|
||
## Hard caps (both modes)
|
||
|
||
| Cap | Default | Source |
|
||
|---|---|---|
|
||
| Min net edge | 50 bps | `ARB_MIN_NET_EDGE_BPS` |
|
||
| Max basket USD | $50 | `ARB_MAX_BASKET_USD` |
|
||
| Max open baskets (global) | 3 | `ARB_MAX_OPEN_BASKETS` |
|
||
| Max open baskets per event | 1 | hard-coded |
|
||
| Daily loss stop | $100 | `ARB_DAILY_LOSS_STOP_USD` |
|
||
| Kill switch | `./KILL` file presence | `ARB_KILL_SWITCH_FILE` |
|
||
| Resolution proximity skip | 24h | hard-coded |
|
||
|
||
## Out of scope for v1
|
||
|
||
- Cross-platform (Kalshi, Manifold, sportsbooks) — separate funding paths, 2× KYC, no atomic exec.
|
||
- Vanilla CTF / non-negRisk arbs — the redeem path differs and the sum-to-1 invariant is more fragile.
|
||
- Maker (passive) strategies — we are takers only.
|
||
- Options-replication arbs vs Deribit — needs a vol-pricing layer.
|
||
|
||
## Phased build
|
||
|
||
| Phase | Scope | Capital risk |
|
||
|---|---|---|
|
||
| 1 | REST discovery + WS book + opportunity engine + paper executor + dashboard | $0 |
|
||
| 2 | Live executor (caps minimum), redeem path | ≤ $50/basket × 3 baskets |
|
||
| 3 | Resolution watcher, daily PnL accounting, denylist | same |
|
||
| 4 | Scale caps with proven track record; backtest harness | configurable |
|