first commit
deploy GitHub Pages / deploy (push) Has been cancelled
tests / test (push) Has been cancelled

This commit is contained in:
2026-07-22 18:53:44 +08:00
commit 9259325d8a
60 changed files with 11081 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
# =============================================================================
# ARBITRAGE BOT CONFIG
# Copy to `.env` and fill in. Never commit `.env`.
# =============================================================================
# --- Mode -------------------------------------------------------------------
# paper = simulate fills against the live book, no keys touched
# live = sign + submit real orders (requires PRIVATE_KEY + funded wallet)
ARB_MODE=paper
# --- Polymarket endpoints ---------------------------------------------------
ARB_CLOB_HOST=https://clob.polymarket.com
ARB_GAMMA_HOST=https://gamma-api.polymarket.com
ARB_DATA_HOST=https://data-api.polymarket.com
ARB_WS_URL=wss://ws-subscriptions-clob.polymarket.com/ws/market
ARB_POLYGON_RPC=https://polygon-rpc.com
# --- Live-mode wallet (DO NOT FILL FOR PAPER MODE) -------------------------
# A dedicated EOA holding USDC.e on Polygon. Never reuse a personal wallet.
ARB_PRIVATE_KEY=
# The address that actually holds USDC. Equal to the EOA for signature_type=0.
# For Polymarket-proxy or Safe, set to the proxy/safe address.
ARB_FUNDER_ADDRESS=
# 0=EOA, 1=Polymarket proxy, 2=Gnosis Safe
ARB_SIGNATURE_TYPE=0
# L2 API credentials. Leave blank to derive on first run via /auth/derive-api-key
ARB_API_KEY=
ARB_API_SECRET=
ARB_API_PASSPHRASE=
# --- Risk limits (apply to both paper and live) ----------------------------
ARB_MIN_NET_EDGE_BPS=50 # opportunities below this are ignored
ARB_MAX_BASKET_USD=50 # max USD per basket attempt
ARB_MAX_OPEN_BASKETS=3 # ceiling on simultaneous unredeemed baskets
ARB_DAILY_LOSS_STOP_USD=100 # halt for the day if realized loss exceeds this
ARB_KILL_SWITCH_FILE=./KILL # if this file exists, refuse to execute
# --- Paper-mode realism ----------------------------------------------------
# How long to delay our "order arrival" against the live book to model
# getting beaten by faster bots. 250ms is conservative.
ARB_PAPER_LATENCY_MS=250
# --- Storage / web ---------------------------------------------------------
ARB_DB_PATH=./arbitrage.db
ARB_WEB_HOST=127.0.0.1
ARB_WEB_PORT=8000
ARB_LOG_LEVEL=INFO
# --- Network (optional) -----------------------------------------------------
# HTTP/HTTPS proxy for both REST and WebSocket (e.g. http://127.0.0.1:7890).
# Leave blank to connect directly.
ARB_PROXY=
+36
View File
@@ -0,0 +1,36 @@
name: deploy GitHub Pages
on:
push:
branches: [main]
paths:
- "docs/**"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: docs
- id: deployment
uses: actions/deploy-pages@v4
+81
View File
@@ -0,0 +1,81 @@
name: refresh event list
on:
schedule:
- cron: "17 * * * *" # hourly at :17
workflow_dispatch:
permissions:
contents: write
concurrency:
group: refresh-events
cancel-in-progress: true
jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch Polymarket events + extract negRisk
shell: bash
run: |
set -euo pipefail
mkdir -p docs/data
curl -sSLf \
"https://gamma-api.polymarket.com/events?closed=false&archived=false&active=true&limit=25&order=volume24hr&ascending=false" \
-o /tmp/raw.json
python3 - <<'PY'
import json, os, datetime, pathlib
raw = json.loads(pathlib.Path('/tmp/raw.json').read_text(encoding='utf-8'))
neg = [e for e in raw if e.get('negRisk')]
out = []
for ev in neg:
markets = ev.get('markets') or []
if len(markets) < 2:
continue
outcomes = []
ok = True
for i, m in enumerate(markets):
if m.get('closed') or m.get('archived'):
ok = False; break
ids = m.get('clobTokenIds')
if isinstance(ids, str):
try: ids = json.loads(ids)
except Exception: ids = None
if not isinstance(ids, list) or not ids:
ok = False; break
name = m.get('groupItemTitle') or m.get('outcome') or m.get('question') or f'Outcome {i+1}'
outcomes.append({'token_id': str(ids[0]), 'name': str(name)})
if not ok or len(outcomes) < 2:
continue
out.append({
'id': ev.get('negRiskMarketID') or str(ev.get('id')),
'title': ev.get('title') or ev.get('slug') or 'Event',
'slug': ev.get('slug'),
'outcomes': outcomes,
})
if len(out) >= 8:
break
payload = {
'generated_at': datetime.datetime.now(datetime.UTC).isoformat(),
'events': out,
}
pathlib.Path('docs/data/events.json').write_text(
json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8'
)
print(f'wrote {len(out)} events')
PY
- name: Commit if changed
run: |
if git diff --quiet docs/data/events.json; then
echo "no change"
exit 0
fi
git config user.name "events-refresh-bot"
git config user.email "events-refresh-bot@users.noreply.github.com"
git add docs/data/events.json
git commit -m "chore(events): refresh snapshot $(date -u +%Y-%m-%dT%H:%MZ)"
git push
+29
View File
@@ -0,0 +1,29 @@
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Lint
run: ruff check arbitrage tests
- name: Tests
run: pytest --cov=arbitrage --cov-report=term-missing
+22
View File
@@ -0,0 +1,22 @@
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.env
*.db
*.db-journal
*.db-wal
*.db-shm
KILL
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
dist/
build/
.idea/
.vscode/
.claude/
logs/
+151
View File
@@ -0,0 +1,151 @@
# 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 |
+31
View File
@@ -0,0 +1,31 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
# Install system deps for web3 / signing libs
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml README.md ./
COPY arbitrage ./arbitrage
RUN pip install --upgrade pip \
&& pip install -e .
# Paper mode by default; no secrets required
ENV ARB_MODE=paper \
ARB_DB_PATH=/data/arbitrage.db \
ARB_WEB_HOST=0.0.0.0 \
ARB_WEB_PORT=8000
VOLUME ["/data"]
EXPOSE 8000
# Entrypoint chooses init→discover→scan→web via docker-compose services.
ENTRYPOINT ["arb"]
CMD ["web"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Matthew Allen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+159
View File
@@ -0,0 +1,159 @@
# 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](https://matthewnyc2.github.io/arbitrage/demo.html)** ·
**[Project overview](https://matthewnyc2.github.io/arbitrage/)** ·
**[Architecture](DESIGN.md)**
![tests](https://img.shields.io/badge/tests-60%20passing-brightgreen)
![coverage](https://img.shields.io/badge/core%20math-94%25-brightgreen)
![python](https://img.shields.io/badge/python-3.12%2B-blue)
![license](https://img.shields.io/badge/license-MIT-blue)
---
## 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
```bash
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
```bash
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 sit `pending_resolution` until the underlying market closes,
then flip to `redeemed` or `invalid` and realized PnL is booked.
- **`ARB_MODE=live`** — signs and submits real orders, redeems complete sets
via `NegRiskAdapter.redeemPositions`. `LiveExecutor.dry_run=True` by 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
```bash
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`](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`.
+235
View File
@@ -0,0 +1,235 @@
# Security Review: Polymarket Arbitrage Bot
**Date:** 2026-07-22
**Scope:** Full codebase (27 Python files, Docker config, web UI, live executor)
**Mode:** Paper mode by default; Live mode requires ARB_MODE=live
---
## Summary
| Severity | Count |
|----------|-------|
| **HIGH** | 3 |
| **MEDIUM** | 4 |
| **LOW** | 3 |
| **INFO** | 3 |
The codebase follows strong security hygiene in several areas (SecretStr config, parameterized SQL, no hardcoded secrets, proper .gitignore). The critical gaps are around the web UI (no auth), the live executor (dry_run=False in scan mode), and the Docker deployment (exposed dashboard with kill/unkill controls).
---
## HIGH Severity
### H1: Live executor runs with `dry_run=False` in `arb scan` — no final confirmation
**File:** `arbitrage/cli.py:87-89`
```python
executor = (
LiveExecutor(books=books, dry_run=False) # ← no dry_run flag in LiveExecutor!
if settings.mode == Mode.LIVE
else PaperExecutor(books=books)
)
```
The `LiveExecutor.__init__` accepts `dry_run` but defaults to `True`. However, the CLI *hardcodes* `dry_run=False` when mode is live. This means `ARB_MODE=live` alone is sufficient to submit real orders. A misconfigured `.env` (or accidental env var bleed from another process) could trigger real trades.
**Recommendation:** Add an explicit CLI flag (`--confirm-live`) or a one-time confirmation prompt. The current single-env-var guard is acceptable for a CLI tool but should be documented prominently.
---
### H2: Web UI `/kill` and `/unkill` have zero authentication
**File:** `arbitrage/web/app.py:77-88`
```python
@app.post("/kill", response_class=HTMLResponse)
async def kill(request: Request) -> HTMLResponse:
settings.kill_switch_file.parent.mkdir(parents=True, exist_ok=True)
settings.kill_switch_file.touch(exist_ok=True)
return await pnl(request)
@app.post("/unkill", response_class=HTMLResponse)
async def unkill(request: Request) -> HTMLResponse:
path = settings.kill_switch_file
if path.exists():
path.unlink()
return await pnl(request)
```
In Docker, the web service binds to `0.0.0.0` and exposes port 8000 (`docker-compose.yml:47-51`). Anyone on the network can POST to `/kill` to halt trading, or POST to `/unkill` to bypass a kill switch. No CSRF token, no auth header, no rate limiting.
**Recommendation:**
- Option A: Add a simple API key header check (e.g., `X-Admin-Key`) for `/kill` and `/unkill`
- Option B: Bind web to `127.0.0.1` in production and use a reverse proxy with auth
- Option C: Add HTTP Basic Auth via uvicorn middleware
---
### H3: Docker web dashboard exposed on `0.0.0.0:8000` with no access control
**File:** `docker-compose.yml:42-52`
```yaml
web:
environment:
- ARB_WEB_HOST=0.0.0.0
- ARB_WEB_PORT=8000
ports:
- "8000:8000"
```
Combined with H2, the entire dashboard (including PnL data, basket details, kill/unkill controls) is accessible to any network participant. The dashboard shows event titles, basket counts, realized PnL, and opportunity details — operational intelligence that should not be public.
**Recommendation:**
- Change `ARB_WEB_HOST` to `127.0.0.1` and remove the port mapping, or
- Add a reverse proxy (nginx/Caddy) with auth before the dashboard
- If remote access is needed, use SSH tunneling or WireGuard
---
## MEDIUM Severity
### M1: `_unwind` sells at `price=0.0` — unprotected market sell
**File:** `arbitrage/engine/live_executor.py:261-272`
```python
args = OrderArgs(
token_id=fill.token_id,
price=0.0, # market — no minimum price protection
size=float(fill.size),
side="SELL",
)
```
When unwinding a partial-fill basket, the executor submits SELL orders with `price=0.0`, which Polymarket interprets as a market order. In a fast-moving market, this could execute at an extremely unfavorable price (e.g., if the token price has dropped to near zero during the unwind).
**Recommendation:** Add a `min_price` parameter (configurable via settings) that sets a floor on the unwind sell price. If the current bid is below the floor, skip the unwind and log it for manual intervention.
---
### M2: `py-clob-client` dependency is unpinned — supply chain risk
**File:** `pyproject.toml:10`
```toml
"py-clob-client>=0.20.0",
```
The docs (`docs/api/order-signing.md:5-7`) explicitly recommend pinning to `0.34.6`. The unpinned `>=` constraint means `pip install` could pull in any version ≥0.20.0, including versions with breaking API changes, security regressions, or supply chain compromises.
**Recommendation:** Pin to `py-clob-client==0.34.6` and add a CI step to verify the pinned version.
---
### M3: Kill switch file is trivially bypassed (delete + unkill)
**File:** `arbitrage/engine/live_executor.py:57-58`, `arbitrage/web/app.py:83-88`
```python
if limits.kill_switch_file.exists():
raise RiskDenied(f"kill switch present: {limits.kill_switch_file}")
```
The kill switch is a simple file existence check (`./KILL` by default). An attacker (or operator) can:
1. Delete the file to bypass it (`rm ./KILL`)
2. Call `/unkill` to remove it remotely
There's no write-lock, no integrity check, no audit log of kill switch state changes.
**Recommendation:** If the kill switch is intended as a hard safety mechanism, use a more robust approach:
- A file with write-once semantics (chmod 444 after creation)
- An in-memory flag + periodic file check (so deletion alone doesn't resume)
- Audit logging of all kill/unkill events
---
### M4: TOCTOU race in `risk_gate` basket count check
**File:** `arbitrage/engine/live_executor.py:65-95`
The `risk_gate` function checks the current number of open baskets, then `execute()` proceeds to submit orders. Between the check and the actual submission, another instance/process could open additional baskets, exceeding the configured limits.
In single-process mode (default), this is low risk. In multi-container deployments (docker-compose has separate `scan` and `discover` services), the risk is higher if multiple `scan` instances run.
**Recommendation:** Use SQLite's `BEGIN IMMEDIATE` or a row-level advisory lock around the risk check + order submission. For production multi-instance deployments, use a Redis-based distributed lock.
---
## LOW Severity
### L1: No rate limiting on web fragment endpoints
**File:** `arbitrage/web/app.py:43-74`
The `/fragments/*` endpoints are polled every 2-3 seconds via HTMX. There's no rate limiting or DoS protection. An attacker could consume excessive DB connections or CPU by spamming requests.
**Recommendation:** Add a simple request counter or `slowapi` middleware. Low priority since the web is intended for local use.
---
### L2: JSHTMX loads from unpinned CDN
**File:** `arbitrage/web/templates/index.html:6`
```html
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
```
While unpinned (`@2.0.3` is specified), unpkg doesn't pin the sub-version. If `2.0.3` is yanked or compromised, a different version could be served. For a self-hosted dashboard this is low risk, but worth noting.
**Recommendation:** Pin the full version hash or self-host the HTMX bundle.
---
### L3: `raw_json` in DB could contain sensitive event metadata
**File:** `arbitrage/clients/polymarket_rest.py:204`
```python
raw_json = json.dumps(raw_by_id.get(ev.id))
```
Full raw Gamma event JSON is persisted to the SQLite `raw_json` column. While this is Polymarket's public data, it's worth considering if any sensitive operational fields leak in (e.g., internal IDs, timestamps that reveal monitoring cadence).
**Recommendation:** Review the Gamma API response shape and selectively store only needed fields. Or accept this as low-risk since the data is public.
---
## INFO (Good Practices Observed)
### I1: Secrets management via `SecretStr` ✅
`config.py:32-36` uses `pydantic.SecretStr` for all sensitive fields (private key, API key, secret, passphrase). `SecretStr.get_secret_value()` is the only way to access plaintext, which prevents accidental logging.
### I2: Parameterized SQL everywhere ✅
All database queries use `?` placeholders with parameter tuples. No string interpolation, no f-strings in SQL. Zero SQL injection risk.
### I3: `.env` properly excluded ✅
`.env` is in `.gitignore` (line 6), `.env.example` contains only defaults. No secrets in source.
### I4: Logging configured to avoid backtraces ✅
`logging_setup.py:13-14` sets `backtrace=False, diagnose=False` — prevents stack traces from leaking sensitive local variable values in log files.
### I5: Mode guard on LiveExecutor ✅
`live_executor.py:117-119` raises `RuntimeError` if instantiated when `ARB_MODE != live`. This is a good belt-and-suspenders check.
### I6: `dry_run` defaults to `True` in LiveExecutor ✅
`live_executor.py:116`: `dry_run: bool = True` — the constructor is safe by default. Only the CLI overrides it.
---
## Risk Matrix (Live Mode Only)
| Threat | Impact | Likelihood | Mitigation Status |
|--------|--------|------------|-------------------|
| Accidental live trade from misconfigured .env | Financial loss | Medium | Partial (mode check) |
| Remote kill/unkill via web UI | Operational | High | None |
| Unprotected market sell during unwind | Financial loss | Low | None |
| Supply chain via unpinned py-clob-client | Unknown | Low | None |
| TOCTOU race on basket limits | Over-exposure | Low-Medium | None |
| XSS in dashboard | Data leak | Very Low | Jinja2 autoescape |
| SQL injection | Data compromise | None | Parameterized queries |
---
## Priority Recommendations
1. **🔴 Add auth to web `/kill` and `/unkill`** — or remove the web server from production Docker
2. **🔴 Add `--confirm-live` flag to `arb scan`** — prevent accidental live execution
3. **🟡 Pin `py-clob-client==0.34.6`** in `pyproject.toml`
4. **🟡 Add min-price protection to `_unwind` SELL orders**
5. **🟡 Change Docker web to `127.0.0.1`** or add reverse proxy auth
6. **🟢 Add rate limiting to web endpoints**
7. **🟢 Self-host or pin HTMX script hash**
+278
View File
@@ -0,0 +1,278 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>arbitrage — status</title>
<style>
:root { color-scheme: light dark; }
body {
font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
max-width: 780px; margin: 2rem auto; padding: 0 1.25rem;
line-height: 1.55; color: #1b1f23;
}
h1 { font-size: 1.6rem; margin-bottom: 0.2rem; }
h2 { font-size: 1.2rem; margin-top: 2rem; padding-bottom: 0.2rem;
border-bottom: 1px solid #e1e4e8; }
h3 { font-size: 1rem; margin-top: 1.4rem; }
p, li { font-size: 0.98rem; }
code, pre { font-family: "SF Mono", Menlo, Consolas, monospace; }
code { background: #f4f5f7; padding: 1px 5px; border-radius: 3px; font-size: 0.9em; }
pre { background: #f4f5f7; padding: 0.75rem 1rem; border-radius: 5px;
overflow-x: auto; font-size: 0.88rem; line-height: 1.45; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 10px;
font-size: 0.75rem; background: #dff0d8; color: #2d6a2d;
vertical-align: middle; margin-left: 0.5rem; }
.tag.warn { background: #fff3cd; color: #7a5d00; }
.tag.cold { background: #e6f0ff; color: #1a4b91; }
.rule { margin: 2rem 0; border: 0; border-top: 1px solid #e1e4e8; }
blockquote { border-left: 3px solid #d0d7de; margin: 1rem 0; padding: 0.4rem 1rem;
color: #57606a; background: #f9f9fa; }
table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #e1e4e8;
font-size: 0.92rem; }
th { background: #f4f5f7; }
@media (prefers-color-scheme: dark) {
body { background: #0e1117; color: #e6edf3; }
code, pre { background: #161b22; color: #e6edf3; }
h2 { border-color: #30363d; }
th { background: #161b22; }
th, td { border-color: #30363d; }
blockquote { color: #8b949e; background: #161b22; border-color: #30363d; }
.tag { background: #1f3a20; color: #9fd39f; }
.tag.warn { background: #3a3200; color: #ffe58a; }
.tag.cold { background: #0f2545; color: #9dc4ff; }
}
</style>
</head>
<body>
<h1>arbitrage — where we are <span class="tag">paper mode</span></h1>
<p><em>Plain-English status doc. Read before you run anything against real money.</em></p>
<hr class="rule" />
<h2>1. What the status is</h2>
<p>
The bot is <strong>code-complete for Phase 1</strong> (paper trading) and has a
<strong>functional-but-unused skeleton for Phase 2</strong> (live trading). It
has never connected to the live Polymarket firehose yet, and it has never
placed a real order. Everything that exists has been unit-tested against
fake data — not yet validated against the real market.
</p>
<table>
<thead><tr><th>Piece</th><th>State</th><th>What that means</th></tr></thead>
<tbody>
<tr><td>SQLite database layer</td><td>done</td>
<td>Tables for events, outcomes, opportunities, baskets, fills, resolutions.</td></tr>
<tr><td>Gamma REST discovery</td><td>done</td>
<td>Pulls the public list of Polymarket events and filters to the "negRisk" categoricals we can arb.</td></tr>
<tr><td>WebSocket L2 order book</td><td>done, never live-tested</td>
<td>Parses Polymarket's published message format. Hasn't shaken hands with the real server yet.</td></tr>
<tr><td>Opportunity engine (math)</td><td>done</td>
<td>For every event, walks the depth on every outcome, computes the best basket size, and reports the net edge after fees + gas.</td></tr>
<tr><td>Paper executor</td><td>done</td>
<td>Pretends to buy, models a 250&nbsp;ms "someone beat me" penalty, records the outcome. No keys needed.</td></tr>
<tr><td>Live executor</td><td>wired, gated off</td>
<td>Will sign and submit orders through <code>py-clob-client</code>. Stays in dry-run mode unless you explicitly flip it.</td></tr>
<tr><td>Risk gate</td><td>done</td>
<td>Hard caps on basket size, open-basket count per event, daily loss, and a kill-switch file.</td></tr>
<tr><td>Dashboard</td><td>done</td>
<td>One HTML page, auto-refreshing. Shows opportunities, baskets, paper PnL, mode, and a kill button.</td></tr>
<tr><td>Resolution watcher</td><td>manual only</td>
<td>You can mark an event as resolved with <code>arb resolve</code>. There is no automatic poller yet — that's Phase&nbsp;3.</td></tr>
<tr><td>Tests</td><td>60 passing</td>
<td>~5 seconds to run. Core math is ≥90% covered. HTTP/socket code is thinner (~55%).</td></tr>
</tbody>
</table>
<blockquote>
Bottom line: you can run it today in paper mode against live Polymarket data
and watch what the engine would have done. You <em>cannot</em> yet point it at
a funded wallet and walk away.
</blockquote>
<h2>2. What it can do</h2>
<h3>The trade it looks for</h3>
<p>
Polymarket hosts "categorical" markets — say, "Who wins the 2028 US Election?"
with one YES token per candidate. Because exactly one candidate wins, the
fair prices of every YES token must sum to exactly $1. When someone in the
order book is selling those YES tokens cheaply enough that the <em>sum of best
offers adds up to less than $1</em>, you can buy one of each and hold a
guaranteed $1 per basket. The gap between that sum and $1 is the profit.
</p>
<h3>What the bot does about it</h3>
<ol>
<li><strong>Discovers</strong> the categorical events on Polymarket.</li>
<li><strong>Subscribes</strong> to their live order books over a WebSocket.</li>
<li><strong>Watches</strong> every book update and re-runs the math.</li>
<li><strong>Sizes</strong> the trade honestly — it walks the depth on each
leg, doesn't trust the top-of-book quote, and subtracts fees and the
Polygon gas cost amortised across the basket.</li>
<li><strong>Logs</strong> every qualifying opportunity to SQLite.</li>
<li><strong>In paper mode:</strong> simulates the fills with a realistic
latency penalty (250 ms by default, modelling "faster bots got there
first"), records what it would have paid, and marks realised PnL when
the event resolves.</li>
<li><strong>In live mode:</strong> submits FAK (fill-and-kill) orders on
every leg in parallel, unwinds anything that partial-fills, and defers
the on-chain redeem to a watcher you still have to build or run
manually.</li>
<li><strong>Respects hard caps</strong> on basket size, total open baskets,
daily loss, and a kill-switch file. It refuses to trade if any of those
trip.</li>
<li><strong>Shows you what it's doing</strong> on a dashboard that refreshes
itself every few seconds.</li>
</ol>
<h2>3. How to use it</h2>
<h3>First-time setup (paper mode, no keys)</h3>
<pre>cd C:\Users\matt\Dropbox\projects\arbitrage
.venv\Scripts\activate
pip install -e .
copy .env.example .env # defaults are fine for paper mode
arb init # create the SQLite database</pre>
<h3>Run it</h3>
<p>
Open three terminals (or use a background process per step). In paper mode
nothing you do here touches real money.
</p>
<pre># Terminal 1 — keep the event list fresh
arb discover --loop
# Terminal 2 — start the scanner (WS + engine + paper executor)
arb scan
# Terminal 3 — dashboard
arb web
# open http://127.0.0.1:8000</pre>
<p>
Walk away for a few hours. Opportunities will log to the dashboard and into
SQLite. Paper baskets will move from <em>pending_resolution</em>
<em>redeemed</em> (or <em>invalid</em>) as the underlying Polymarket events
close out. At that point your paper realised-PnL column tells you whether
the strategy would have been worth running.
</p>
<h3>Kill switch</h3>
<p>
Click the red <strong>kill</strong> button on the dashboard, or
<code>touch KILL</code> in the project folder. The executor refuses to open
any new baskets while that file exists.
</p>
<h3>Manually resolving an event (until the auto-watcher exists)</h3>
<pre>arb resolve &lt;negRiskMarketID&gt; --winner &lt;winning_token_id&gt;
arb resolve &lt;negRiskMarketID&gt; --winner invalid # UMA voided it</pre>
<h3>Going live (not recommended yet — see §4)</h3>
<p>Edit <code>.env</code>:</p>
<pre>ARB_MODE=live
ARB_PRIVATE_KEY=0x... # dedicated wallet, small balance
ARB_FUNDER_ADDRESS=0x... # same as wallet for EOA
ARB_MAX_BASKET_USD=25 # keep it tiny for first real runs
ARB_MAX_OPEN_BASKETS=1</pre>
<p>
Even after you do this, the live executor defaults to <code>dry_run=True</code>
in code — meaning it still logs what it <em>would</em> have signed rather than
broadcasting. To actually broadcast orders you have to change the
<code>dry_run</code> flag in <code>arbitrage/cli.py</code>. This is an
intentional safety belt.
</p>
<h2>4. Will it work in the real world?</h2>
<p><strong>Honest answer: the code will run. Whether it will <em>make money</em>
is an open empirical question — and the default expectation should be "no,
not yet."</strong></p>
<h3>Why that's the default answer</h3>
<ul>
<li><strong>This arb is old news.</strong> NegRisk categorical mispricings
on Polymarket are well known to every market-making bot on the
platform. The big ones are usually closed out in milliseconds. What
remains is the long tail of briefly-stale quotes on thin markets near
event close.</li>
<li><strong>We are slow compared to the pros.</strong> Our scanner is a
Python process in the US on residential internet, reading a public
WebSocket. A serious HFT-style competitor runs closer to the CLOB and
uses pre-signed order batches. The 250&nbsp;ms paper-mode latency
penalty is meant to approximate being behind them, and it is probably
optimistic.</li>
<li><strong>Depth is shallow at the edge.</strong> When the math does
cross, there are usually only a few hundred dollars of lift available
before the sum climbs back above $1. Fee-wise the trade still works;
capital efficiency-wise it's unimpressive.</li>
<li><strong>Resolution risk is real.</strong> Polymarket events settle via
UMA's optimistic oracle. A disputed market can resolve differently
than you expected or even "invalid" with zero payout. The strategy is
not actually risk-free — it is risk-<em>small</em> most of the time
and occasionally very expensive. The denylist and proximity-to-close
skip exist for this reason.</li>
<li><strong>Gas is hardcoded.</strong> We assume $0.10 of Polygon gas per
basket. During a gas spike that assumption breaks, and the bot will
happily enter trades that are actually negative-EV after real gas.
Wiring a gas oracle is on the Phase&nbsp;3 list.</li>
<li><strong>We haven't proved the WebSocket works.</strong> The parser
matches Polymarket's documented format and the test suite exercises
it, but there is no substitute for a connection test against the real
server. That is the first thing to do when you flip it on.</li>
</ul>
<h3>What a realistic path forward looks like</h3>
<ol>
<li><strong>Paper-mode burn-in, one week.</strong> Run it continuously,
watch the dashboard fill up, let events resolve. Total realised paper
PnL at the end of that week is your headline number. If it's net
negative after the latency penalty, don't even think about live mode.</li>
<li><strong>Investigate the misses.</strong> Every time the paper executor
writes a <em>failed</em> basket (because depth vanished during the
latency window) that's a trade a faster bot beat us to. Ratio of
<em>failed</em> to <em>redeemed</em> is a proxy for how competitive the
environment is.</li>
<li><strong>Tune or give up.</strong> If paper PnL is marginal, try a
tighter minimum edge, larger basket sizes, only near-close events, or
only low-volume events where the HFT bots don't bother. If it's still
marginal, this strategy on this venue may simply be claimed.</li>
<li><strong>Only then, flip live with a tiny cap.</strong> $25 basket, one
open at a time, one event. Expect slippage between paper and live PnL
of at least 2050% because the simulator can't model every
micro-structural detail.</li>
</ol>
<h3>What would make me optimistic</h3>
<p>
Paper realised PnL of roughly <strong>+0.5% per basket, averaged across
≥50 baskets</strong>, with failure rate under a third. Below that, there
isn't enough margin to survive the gap between simulated and live execution.
</p>
<p>
Above that, you still have to check that the opportunities it claims are
scattered across many events (rather than concentrated in one lucky market
that resolved favourably), and that the PnL is stable week-over-week rather
than driven by one big win. Arbitrage should look boring and small on the
balance curve — a straight line tilted gently up. If paper mode produces a
hockey stick, something is wrong with the simulation and it will not
replicate live.
</p>
<hr class="rule" />
<p style="color: #57606a; font-size: 0.85rem;">
Generated alongside commit for Phase 1 MVP, 60 passing tests, 71% total
coverage (94% on the core math). Everything in this doc reflects the code
as of this moment. If you change the strategy, the rebalancing of sections
1 and 2 is on you — section 4 will always age faster than the others.
</p>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
View File
+236
View File
@@ -0,0 +1,236 @@
"""In-memory L2 order book per token.
Kept deliberately small: a sorted dict per side (price -> size), plus a bit of
bookkeeping to detect desync. The opportunity engine consumes immutable
`Book` pydantic snapshots rendered from this structure on every update.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from datetime import UTC, datetime
from decimal import Decimal
from enum import Enum
from typing import Self
from sortedcontainers import SortedDict
from ..models import Book, BookLevel
REMOVE_AT_ZERO = Decimal(0)
class Side(str, Enum):
BID = "BID"
ASK = "ASK"
def _to_decimal(v: object) -> Decimal:
if isinstance(v, Decimal):
return v
if isinstance(v, (int, float)):
return Decimal(str(v))
if isinstance(v, str):
return Decimal(v)
raise TypeError(f"cannot coerce {type(v).__name__} to Decimal")
def _coerce_side(v: object) -> Side:
if isinstance(v, Side):
return v
s = str(v).strip().upper()
if s in ("BUY", "BID", "B"):
return Side.BID
if s in ("SELL", "ASK", "S", "A"):
return Side.ASK
raise ValueError(f"unknown side: {v!r}")
@dataclass(slots=True)
class LevelChange:
price: Decimal
size: Decimal
side: Side
@classmethod
def from_raw(cls, raw: dict[str, object]) -> Self:
return cls(
price=_to_decimal(raw["price"]),
size=_to_decimal(raw["size"]),
side=_coerce_side(raw["side"]),
)
@dataclass(slots=True)
class LiveBook:
"""Mutable L2 book for one token. Bids and asks are sorted price ladders."""
token_id: str
bids: SortedDict = field(default_factory=SortedDict) # price (Decimal) -> size (Decimal)
asks: SortedDict = field(default_factory=SortedDict)
snapshots_applied: int = 0
deltas_applied: int = 0
last_update: datetime | None = None
last_hash: str | None = None
def apply_snapshot(
self,
*,
bids: list[tuple[Decimal, Decimal]],
asks: list[tuple[Decimal, Decimal]],
timestamp: datetime | None = None,
book_hash: str | None = None,
) -> None:
self.bids.clear()
self.asks.clear()
for price, size in bids:
if size > 0:
self.bids[price] = size
for price, size in asks:
if size > 0:
self.asks[price] = size
self.snapshots_applied += 1
self.last_update = timestamp or datetime.now(UTC)
self.last_hash = book_hash
def apply_delta(
self,
changes: list[LevelChange],
*,
timestamp: datetime | None = None,
book_hash: str | None = None,
) -> None:
for ch in changes:
side_map = self.bids if ch.side is Side.BID else self.asks
if ch.size <= REMOVE_AT_ZERO:
side_map.pop(ch.price, None)
else:
side_map[ch.price] = ch.size
self.deltas_applied += 1
self.last_update = timestamp or datetime.now(UTC)
self.last_hash = book_hash
def best_bid(self) -> tuple[Decimal, Decimal] | None:
if not self.bids:
return None
price = self.bids.keys()[-1]
return (price, self.bids[price])
def best_ask(self) -> tuple[Decimal, Decimal] | None:
if not self.asks:
return None
price = self.asks.keys()[0]
return (price, self.asks[price])
def vwap_buy(self, target_size: Decimal) -> tuple[Decimal, Decimal, int] | None:
"""Walk asks ascending to fill `target_size`.
Returns (vwap_price, filled_size, levels_consumed). If unable to fill any, None.
"""
if target_size <= 0 or not self.asks:
return None
remaining = target_size
cost = Decimal(0)
filled = Decimal(0)
levels = 0
for price in self.asks.keys():
size = self.asks[price]
take = min(remaining, size)
cost += take * price
filled += take
levels += 1
remaining -= take
if remaining <= 0:
break
if filled <= 0:
return None
return (cost / filled, filled, levels)
def to_snapshot(self) -> Book:
bids = [
BookLevel(price=p, size=s)
for p, s in reversed(list(self.bids.items())) # descending
]
asks = [BookLevel(price=p, size=s) for p, s in self.asks.items()]
return Book(
token_id=self.token_id,
bids=bids,
asks=asks,
sequence=self.snapshots_applied + self.deltas_applied,
updated_at=self.last_update or datetime.now(UTC),
)
@dataclass(slots=True)
class BookUpdate:
"""Emitted whenever a token's book is mutated."""
token_id: str
reason: str # "snapshot" | "delta"
at: datetime
class BookRegistry:
"""Holds LiveBooks and multiplexes update events to subscribers."""
def __init__(self) -> None:
self._books: dict[str, LiveBook] = {}
self._subscribers: list[asyncio.Queue[BookUpdate]] = []
def book(self, token_id: str) -> LiveBook:
book = self._books.get(token_id)
if book is None:
book = LiveBook(token_id=token_id)
self._books[token_id] = book
return book
def get(self, token_id: str) -> LiveBook | None:
return self._books.get(token_id)
def tokens(self) -> list[str]:
return list(self._books.keys())
def apply_snapshot(
self,
token_id: str,
*,
bids: list[tuple[Decimal, Decimal]],
asks: list[tuple[Decimal, Decimal]],
timestamp: datetime | None = None,
book_hash: str | None = None,
) -> None:
self.book(token_id).apply_snapshot(
bids=bids, asks=asks, timestamp=timestamp, book_hash=book_hash
)
self._publish(BookUpdate(token_id=token_id, reason="snapshot", at=datetime.now(UTC)))
def apply_delta(
self,
token_id: str,
changes: list[LevelChange],
*,
timestamp: datetime | None = None,
book_hash: str | None = None,
) -> None:
self.book(token_id).apply_delta(
changes, timestamp=timestamp, book_hash=book_hash
)
self._publish(BookUpdate(token_id=token_id, reason="delta", at=datetime.now(UTC)))
async def updates(self, maxsize: int = 1024) -> AsyncIterator[BookUpdate]:
queue: asyncio.Queue[BookUpdate] = asyncio.Queue(maxsize=maxsize)
self._subscribers.append(queue)
try:
while True:
yield await queue.get()
finally:
self._subscribers.remove(queue)
def _publish(self, update: BookUpdate) -> None:
for q in self._subscribers:
try:
q.put_nowait(update)
except asyncio.QueueFull:
# If a consumer can't keep up, drop — the engine uses the
# *current* book state anyway, so missing a tick is harmless.
pass
+138
View File
@@ -0,0 +1,138 @@
"""Arbitrage CLI — `arb <subcommand>`.
Subcommands:
arb init Create the SQLite schema.
arb discover One pass of Gamma REST discovery (or --loop for forever).
arb scan Full paper loop: WS + engine + paper executor.
arb web Start the FastAPI + HTMX dashboard.
arb resolve Mark a resolution (manual fallback).
"""
from __future__ import annotations
import argparse
import asyncio
from collections.abc import Sequence
from .config import Mode, settings
from .logging_setup import configure_logging
def _parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="arb", description="Polymarket arbitrage")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("init", help="create SQLite schema")
disc = sub.add_parser("discover", help="one pass of Gamma REST discovery")
disc.add_argument("--loop", action="store_true", help="run forever")
disc.add_argument("--interval", type=int, default=120, help="seconds between passes")
disc.add_argument("--max-pages", type=int, default=20)
sub.add_parser("scan", help="run the paper scan loop (WS + engine + executor)")
webp = sub.add_parser("web", help="start the dashboard")
webp.add_argument("--host", default=settings.web_host)
webp.add_argument("--port", type=int, default=settings.web_port)
res = sub.add_parser("resolve", help="mark an event as resolved")
res.add_argument("event_id")
res.add_argument(
"--winner",
default=None,
help="winning token_id (omit or set to 'invalid' for an invalid resolution)",
)
res.add_argument("--source", default="manual")
return p
async def _cmd_init() -> None:
from .db import init_db
await init_db()
print(f"initialized {settings.db_path}")
async def _cmd_discover(loop: bool, interval: int, max_pages: int) -> None:
from .clients.polymarket_rest import discover_once, discovery_loop
if loop:
await discovery_loop(interval_seconds=interval)
else:
stats = await discover_once(max_pages=max_pages)
print(
f"seen={stats.events_seen} negRisk={stats.neg_risk_events} "
f"upserted={stats.upserted} malformed={stats.skipped_malformed}"
)
async def _cmd_scan() -> None:
from .book.l2 import BookRegistry
from .clients.polymarket_ws import run_market_channels
from .engine.live_executor import LiveExecutor
from .engine.loop import hydrate_event_index, run_scan_loop
from .engine.opportunity import EventIndex, OpportunityEngine
from .engine.paper_fills import PaperExecutor
books = BookRegistry()
index = EventIndex()
hydrated = await hydrate_event_index(index)
if hydrated == 0:
print("no events in DB; run `arb discover` first")
return
token_ids = list(index.by_token_id.keys())
engine = OpportunityEngine(books=books, index=index)
executor = (
LiveExecutor(books=books, dry_run=False)
if settings.mode == Mode.LIVE
else PaperExecutor(books=books)
)
ws_task = asyncio.create_task(
run_market_channels(token_ids, registry=books), name="ws"
)
try:
await run_scan_loop(books=books, index=index, engine=engine, executor=executor)
finally:
ws_task.cancel()
try:
await ws_task
except asyncio.CancelledError:
pass
def _cmd_web(host: str, port: int) -> None:
import uvicorn
uvicorn.run("arbitrage.web.app:app", host=host, port=port, reload=False)
async def _cmd_resolve(event_id: str, winner: str | None, source: str) -> None:
from .engine.paper_fills import mark_resolution
winning = None if (winner is None or winner.lower() == "invalid") else winner
updated = await mark_resolution(event_id, winning_token_id=winning, source=source)
print(f"resolved event {event_id}: {updated} basket(s) updated")
def main(argv: Sequence[str] | None = None) -> int:
configure_logging()
args = _parser().parse_args(argv)
if args.cmd == "init":
asyncio.run(_cmd_init())
elif args.cmd == "discover":
asyncio.run(_cmd_discover(args.loop, args.interval, args.max_pages))
elif args.cmd == "scan":
asyncio.run(_cmd_scan())
elif args.cmd == "web":
_cmd_web(args.host, args.port)
elif args.cmd == "resolve":
asyncio.run(_cmd_resolve(args.event_id, args.winner, args.source))
else: # argparse guarantees required=True; belt-and-braces
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
View File
+317
View File
@@ -0,0 +1,317 @@
"""Polymarket Gamma REST client.
Paginates /events, filters to active negRisk categoricals, and normalizes each
event into our pydantic `Event` model. The shape of a Gamma event is documented
in docs/api/negrisk.md; we read only the fields we need and ignore the rest.
Refresh cadence and persistence live here too — the whole subsystem is kept in
one file since the only consumer is the opportunity engine.
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator, Iterable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
import httpx
import orjson
from loguru import logger
from tenacity import (
AsyncRetrying,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from ..config import settings
from ..db import db_conn
from ..models import Event, Outcome
GAMMA_EVENTS_PATH = "/events"
DEFAULT_PAGE_SIZE = 100
DEFAULT_TIMEOUT_S = 20.0
@dataclass(slots=True)
class DiscoveryStats:
pages_fetched: int = 0
events_seen: int = 0
neg_risk_events: int = 0
upserted: int = 0
skipped_inactive: int = 0
skipped_not_neg_risk: int = 0
skipped_malformed: int = 0
class GammaClient:
"""Thin async wrapper around Polymarket's Gamma REST API."""
def __init__(
self,
*,
host: str | None = None,
client: httpx.AsyncClient | None = None,
page_size: int = DEFAULT_PAGE_SIZE,
timeout_s: float = DEFAULT_TIMEOUT_S,
) -> None:
self._host = (host or settings.gamma_host).rstrip("/")
self._page_size = page_size
self._timeout_s = timeout_s
self._owns_client = client is None
self._client = client or httpx.AsyncClient(
base_url=self._host,
timeout=timeout_s,
headers={"accept": "application/json"},
proxy=settings.proxy,
)
async def __aenter__(self) -> GammaClient:
return self
async def __aexit__(self, *_: object) -> None:
await self.aclose()
async def aclose(self) -> None:
if self._owns_client:
await self._client.aclose()
async def iter_active_event_pages(
self,
*,
order: str = "volume24hr",
ascending: bool = False,
max_pages: int = 50,
) -> AsyncIterator[list[dict[str, Any]]]:
"""Yield each page of raw event dicts until a short page or max_pages."""
offset = 0
for _ in range(max_pages):
params = {
"closed": "false",
"archived": "false",
"active": "true",
"limit": self._page_size,
"offset": offset,
"order": order,
"ascending": "true" if ascending else "false",
}
page = await self._get_json(GAMMA_EVENTS_PATH, params)
if not isinstance(page, list):
logger.warning("Gamma /events returned non-list: {!r}", type(page))
return
if not page:
return
yield page
if len(page) < self._page_size:
return
offset += self._page_size
async def _get_json(self, path: str, params: dict[str, Any]) -> Any:
async for attempt in AsyncRetrying(
stop=stop_after_attempt(4),
wait=wait_exponential(multiplier=0.5, max=5.0),
retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)),
reraise=True,
):
with attempt:
resp = await self._client.get(path, params=params)
resp.raise_for_status()
return orjson.loads(resp.content)
raise RuntimeError("unreachable")
def normalize_event(raw: dict[str, Any]) -> Event | None:
"""Translate a raw Gamma event dict into our `Event` model.
Returns None if the event is not an active negRisk categorical, or if its
required fields are missing/malformed.
"""
if not raw.get("negRisk"):
return None
markets = raw.get("markets") or []
if len(markets) < 2:
return None
event_id = raw.get("negRiskMarketID") or str(raw.get("id") or "")
if not event_id:
return None
outcomes: list[Outcome] = []
for idx, m in enumerate(markets):
if m.get("closed") or m.get("archived"):
return None
token_ids_raw = m.get("clobTokenIds")
if isinstance(token_ids_raw, str):
try:
token_ids = orjson.loads(token_ids_raw)
except orjson.JSONDecodeError:
return None
else:
token_ids = token_ids_raw
if not isinstance(token_ids, list) or len(token_ids) < 1:
return None
yes_token_id = str(token_ids[0])
if not yes_token_id:
return None
name = (
m.get("groupItemTitle")
or m.get("outcome")
or m.get("question")
or f"Outcome {idx + 1}"
)
outcomes.append(Outcome(token_id=yes_token_id, name=str(name), outcome_index=idx))
if len(outcomes) < 2:
return None
seen: set[str] = set()
for o in outcomes:
if o.token_id in seen:
return None
seen.add(o.token_id)
end_date = _parse_end_date(raw.get("endDate"))
return Event(
id=event_id,
slug=str(raw.get("slug") or event_id),
title=str(raw.get("title") or raw.get("slug") or event_id),
is_neg_risk=True,
end_date=end_date,
outcomes=tuple(outcomes),
)
def _parse_end_date(value: Any) -> datetime | None:
if not value or not isinstance(value, str):
return None
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
async def upsert_events(events: Iterable[Event], *, raw_by_id: dict[str, Any] | None = None) -> int:
"""Persist events + outcomes to SQLite. Returns count of events upserted."""
now = datetime.now(UTC).isoformat()
raw_by_id = raw_by_id or {}
count = 0
async with db_conn() as conn:
for ev in events:
raw_json = json.dumps(raw_by_id.get(ev.id)) if ev.id in raw_by_id else None
await conn.execute(
"""
INSERT INTO events (id, slug, title, is_neg_risk, end_date, active, raw_json, updated_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?)
ON CONFLICT(id) DO UPDATE SET
slug=excluded.slug,
title=excluded.title,
is_neg_risk=excluded.is_neg_risk,
end_date=excluded.end_date,
active=1,
raw_json=COALESCE(excluded.raw_json, events.raw_json),
updated_at=excluded.updated_at
""",
(
ev.id,
ev.slug,
ev.title,
1 if ev.is_neg_risk else 0,
ev.end_date.isoformat() if ev.end_date else None,
raw_json,
now,
),
)
for o in ev.outcomes:
await conn.execute(
"""
INSERT INTO outcomes (token_id, event_id, name, outcome_index, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(token_id) DO UPDATE SET
event_id=excluded.event_id,
name=excluded.name,
outcome_index=excluded.outcome_index,
updated_at=excluded.updated_at
""",
(o.token_id, ev.id, o.name, o.outcome_index, now),
)
count += 1
await conn.commit()
return count
async def mark_inactive(kept_event_ids: set[str]) -> int:
"""Mark any previously-active event not in `kept_event_ids` as inactive."""
if not kept_event_ids:
return 0
now = datetime.now(UTC).isoformat()
placeholders = ",".join("?" for _ in kept_event_ids)
async with db_conn() as conn:
cursor = await conn.execute(
f"UPDATE events SET active=0, updated_at=? "
f"WHERE active=1 AND id NOT IN ({placeholders})",
(now, *kept_event_ids),
)
await conn.commit()
return cursor.rowcount or 0
async def discover_once(
*,
client: GammaClient | None = None,
max_pages: int = 20,
) -> DiscoveryStats:
"""Single pass: walk Gamma, filter to negRisk, persist, mark drops inactive."""
stats = DiscoveryStats()
owned: GammaClient | None = None
if client is None:
owned = GammaClient()
client = owned
events: list[Event] = []
raw_by_id: dict[str, Any] = {}
try:
async for page in client.iter_active_event_pages(max_pages=max_pages):
stats.pages_fetched += 1
for raw in page:
stats.events_seen += 1
if not raw.get("negRisk"):
stats.skipped_not_neg_risk += 1
continue
stats.neg_risk_events += 1
if raw.get("closed") or raw.get("archived"):
stats.skipped_inactive += 1
continue
ev = normalize_event(raw)
if ev is None:
stats.skipped_malformed += 1
continue
events.append(ev)
raw_by_id[ev.id] = raw
finally:
if owned is not None:
await owned.aclose()
stats.upserted = await upsert_events(events, raw_by_id=raw_by_id)
await mark_inactive({ev.id for ev in events})
logger.info(
"discovery: seen={}, negRisk={}, upserted={}, malformed={}",
stats.events_seen,
stats.neg_risk_events,
stats.upserted,
stats.skipped_malformed,
)
return stats
async def discovery_loop(interval_seconds: int = 120) -> None:
"""Run discover_once forever, sleeping between passes."""
while True:
try:
await discover_once()
except Exception as exc:
logger.exception("discovery pass failed: {}", exc)
await asyncio.sleep(interval_seconds)
+282
View File
@@ -0,0 +1,282 @@
"""Polymarket CLOB market-channel WebSocket subscriber.
Wire format reference (April 2026):
- endpoint: wss://ws-subscriptions-clob.polymarket.com/ws/market
- subscribe: {"type":"market","assets_ids":[...],"custom_feature_enabled":true}
- snapshot: event_type="book", asks/bids are lists of {price,size} strings
- delta: event_type="price_change", wrapper="price_changes",
side="BUY"|"SELL", size="0" means remove
- keepalive: send the literal text frame "PING" every ~10s
- desync: no sequence numbers; on hash mismatch or missed heartbeat,
drop state and re-subscribe (server re-pushes snapshot)
- sharding: cap tokens/socket around 100-200; open more sockets as needed
"""
from __future__ import annotations
import asyncio
import itertools
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from typing import Any
import orjson
import websockets
from loguru import logger
from websockets.asyncio.client import ClientConnection
from websockets.exceptions import ConnectionClosed
from ..book.l2 import BookRegistry, LevelChange, Side
from ..config import settings
import socket
DEFAULT_SHARD_SIZE = 100
PING_INTERVAL_S = 10.0
RECONNECT_BACKOFF_S = (1.0, 2.0, 4.0, 8.0, 15.0)
async def _tunnel_through_proxy(uri: str) -> socket.socket | None:
"""Open an HTTP CONNECT tunnel to the WS host via the configured proxy.
Returns a connected socket ready for the WS handshake, or None if no proxy.
"""
if not settings.proxy:
return None
from urllib.parse import urlparse
parsed = urlparse(uri)
proxy_parsed = urlparse(settings.proxy)
host, port = parsed.hostname, parsed.port or (443 if parsed.scheme == "wss" else 80)
sock = socket.create_connection((proxy_parsed.hostname, proxy_parsed.port), timeout=10)
connect_req = (
f"CONNECT {host}:{port} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
f"Proxy-Connection: keep-alive\r\n\r\n"
)
sock.sendall(connect_req.encode())
resp = b""
while b"\r\n\r\n" not in resp:
chunk = sock.recv(4096)
if not chunk:
raise OSError("proxy closed before CONNECT response")
resp += chunk
status = resp.split(b"\r\n", 1)[0]
if b" 200 " not in status:
sock.close()
raise OSError(f"proxy CONNECT failed: {status!r}")
sock.settimeout(None)
return sock
def _to_decimal(v: object, default: Decimal = Decimal(0)) -> Decimal:
try:
return Decimal(str(v))
except (InvalidOperation, TypeError, ValueError):
return default
def _parse_ts_ms(v: Any) -> datetime:
try:
ms = int(v)
except (TypeError, ValueError):
return datetime.now(UTC)
return datetime.fromtimestamp(ms / 1000.0, tz=UTC)
def _parse_level_list(raw: Any) -> list[tuple[Decimal, Decimal]]:
out: list[tuple[Decimal, Decimal]] = []
if not isinstance(raw, list):
return out
for lvl in raw:
if not isinstance(lvl, dict):
continue
price = _to_decimal(lvl.get("price"))
size = _to_decimal(lvl.get("size"))
if price <= 0:
continue
out.append((price, size))
return out
def _side_from_buy_sell(raw: Any) -> Side | None:
if not isinstance(raw, str):
return None
s = raw.strip().upper()
if s == "BUY":
return Side.BID
if s == "SELL":
return Side.ASK
return None
class MarketChannel:
"""Maintains a single WS connection for one shard of token ids."""
def __init__(
self,
token_ids: list[str],
*,
registry: BookRegistry,
url: str | None = None,
) -> None:
if not token_ids:
raise ValueError("MarketChannel needs at least one token id")
self._token_ids = list(token_ids)
self._registry = registry
self._url = url or settings.ws_url
self._stop = asyncio.Event()
async def run(self) -> None:
backoff_cycle = itertools.cycle(RECONNECT_BACKOFF_S)
while not self._stop.is_set():
try:
await self._connect_and_consume()
# Clean exit (e.g. stop requested); break loop.
if self._stop.is_set():
return
delay = 1.0
except (TimeoutError, ConnectionClosed, OSError) as exc:
delay = next(backoff_cycle)
logger.warning(
"ws disconnect ({}); reconnecting in {}s", exc.__class__.__name__, delay
)
except Exception as exc:
delay = next(backoff_cycle)
logger.exception("ws fatal ({}); reconnecting in {}s", exc, delay)
await asyncio.sleep(delay)
def stop(self) -> None:
self._stop.set()
async def _connect_and_consume(self) -> None:
sock = await _tunnel_through_proxy(self._url)
connect_kwargs = {"max_size": 2**22}
if sock is not None:
connect_kwargs["sock"] = sock
async with websockets.connect(self._url, **connect_kwargs) as ws:
await self._subscribe(ws)
heartbeat = asyncio.create_task(self._heartbeat(ws))
try:
async for raw in ws:
if self._stop.is_set():
break
self._handle_message(raw)
finally:
heartbeat.cancel()
try:
await heartbeat
except asyncio.CancelledError:
pass
async def _subscribe(self, ws: ClientConnection) -> None:
msg = orjson.dumps(
{
"type": "market",
"assets_ids": self._token_ids,
"custom_feature_enabled": True,
}
)
await ws.send(msg)
logger.info("ws subscribed to {} tokens", len(self._token_ids))
async def _heartbeat(self, ws: ClientConnection) -> None:
while not self._stop.is_set():
await asyncio.sleep(PING_INTERVAL_S)
try:
await ws.send("PING")
except ConnectionClosed:
return
def _handle_message(self, raw: str | bytes) -> None:
if isinstance(raw, str):
if raw.strip() in ("PONG", "PING"):
return
payload = orjson.loads(raw)
else:
# Binary frames shouldn't normally arrive from this channel.
try:
payload = orjson.loads(raw)
except orjson.JSONDecodeError:
return
if isinstance(payload, list):
for item in payload:
self._dispatch(item)
elif isinstance(payload, dict):
self._dispatch(payload)
def _dispatch(self, msg: dict[str, Any]) -> None:
event_type = msg.get("event_type")
if event_type == "book":
self._apply_book(msg)
elif event_type == "price_change":
self._apply_price_change(msg)
elif event_type in ("tick_size_change", "last_trade_price", "best_bid_ask"):
# Not used by the arb math today; logged at debug.
logger.debug("ws {}: {}", event_type, msg)
elif event_type in ("new_market", "market_resolved"):
logger.info("ws {}: {}", event_type, msg)
else:
logger.debug("ws unknown event_type={}: {}", event_type, msg)
def _apply_book(self, msg: dict[str, Any]) -> None:
asset_id = msg.get("asset_id")
if not isinstance(asset_id, str):
return
bids = _parse_level_list(msg.get("bids"))
asks = _parse_level_list(msg.get("asks"))
ts = _parse_ts_ms(msg.get("timestamp"))
self._registry.apply_snapshot(
asset_id, bids=bids, asks=asks, timestamp=ts, book_hash=msg.get("hash")
)
def _apply_price_change(self, msg: dict[str, Any]) -> None:
ts = _parse_ts_ms(msg.get("timestamp"))
changes_raw = msg.get("price_changes")
if not isinstance(changes_raw, list):
return
by_asset: dict[str, list[LevelChange]] = {}
latest_hash: dict[str, str] = {}
for c in changes_raw:
if not isinstance(c, dict):
continue
asset_id = c.get("asset_id")
side = _side_from_buy_sell(c.get("side"))
if not isinstance(asset_id, str) or side is None:
continue
price = _to_decimal(c.get("price"))
size = _to_decimal(c.get("size"))
if price <= 0:
continue
by_asset.setdefault(asset_id, []).append(
LevelChange(price=price, size=size, side=side)
)
h = c.get("hash")
if isinstance(h, str):
latest_hash[asset_id] = h
for asset_id, changes in by_asset.items():
self._registry.apply_delta(
asset_id,
changes,
timestamp=ts,
book_hash=latest_hash.get(asset_id),
)
def shard_tokens(token_ids: list[str], shard_size: int = DEFAULT_SHARD_SIZE) -> list[list[str]]:
return [token_ids[i : i + shard_size] for i in range(0, len(token_ids), shard_size)]
async def run_market_channels(
token_ids: list[str],
*,
registry: BookRegistry,
shard_size: int = DEFAULT_SHARD_SIZE,
) -> None:
"""Run one MarketChannel per shard concurrently. Returns when all exit."""
if not token_ids:
return
channels = [MarketChannel(shard, registry=registry) for shard in shard_tokens(token_ids, shard_size)]
try:
await asyncio.gather(*(ch.run() for ch in channels))
finally:
for ch in channels:
ch.stop()
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
from decimal import Decimal
from enum import Enum
from pathlib import Path
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Mode(str, Enum):
PAPER = "paper"
LIVE = "live"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_prefix="ARB_",
extra="ignore",
)
mode: Mode = Mode.PAPER
clob_host: str = "https://clob.polymarket.com"
gamma_host: str = "https://gamma-api.polymarket.com"
data_host: str = "https://data-api.polymarket.com"
ws_url: str = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
polygon_rpc: str = "https://polygon-rpc.com"
proxy: str | None = None
private_key: SecretStr | None = None
funder_address: str | None = None
signature_type: int = 0
api_key: SecretStr | None = None
api_secret: SecretStr | None = None
api_passphrase: SecretStr | None = None
min_net_edge_bps: int = 50
max_basket_usd: Decimal = Field(default=Decimal("50"))
max_open_baskets: int = 3
max_open_baskets_per_event: int = 1
daily_loss_stop_usd: Decimal = Field(default=Decimal("100"))
kill_switch_file: Path = Path("./KILL")
resolution_skip_hours: int = 24
paper_latency_ms: int = 250
db_path: Path = Path("./arbitrage.db")
web_host: str = "127.0.0.1"
web_port: int = 8000
log_level: str = "INFO"
@property
def is_live(self) -> bool:
return self.mode == Mode.LIVE
def require_live_credentials(self) -> None:
if not self.is_live:
return
missing = [
name
for name, val in [
("ARB_PRIVATE_KEY", self.private_key),
("ARB_FUNDER_ADDRESS", self.funder_address),
]
if not val
]
if missing:
raise RuntimeError(
f"Live mode requires: {', '.join(missing)}. Set them in .env or run paper mode."
)
settings = Settings()
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
import aiosqlite
from .config import settings
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
slug TEXT,
title TEXT,
is_neg_risk INTEGER NOT NULL,
end_date TEXT,
active INTEGER NOT NULL DEFAULT 1,
raw_json TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS outcomes (
token_id TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
name TEXT NOT NULL,
outcome_index INTEGER NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (event_id) REFERENCES events(id)
);
CREATE INDEX IF NOT EXISTS idx_outcomes_event ON outcomes(event_id);
CREATE TABLE IF NOT EXISTS opportunities (
id TEXT PRIMARY KEY,
detected_at TEXT NOT NULL,
event_id TEXT NOT NULL,
event_title TEXT NOT NULL,
sum_vwap_asks TEXT NOT NULL,
net_edge_bps INTEGER NOT NULL,
max_baskets TEXT NOT NULL,
expected_profit_usd TEXT NOT NULL,
legs_json TEXT NOT NULL,
acted_on INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_opps_detected ON opportunities(detected_at DESC);
CREATE INDEX IF NOT EXISTS idx_opps_event ON opportunities(event_id);
CREATE TABLE IF NOT EXISTS baskets (
id TEXT PRIMARY KEY,
opportunity_id TEXT NOT NULL,
event_id TEXT NOT NULL,
is_paper INTEGER NOT NULL,
created_at TEXT NOT NULL,
basket_count TEXT NOT NULL,
total_cost_usd TEXT NOT NULL,
status TEXT NOT NULL,
redeemed_at TEXT,
redeemed_payout_usd TEXT,
realized_pnl_usd TEXT,
FOREIGN KEY (opportunity_id) REFERENCES opportunities(id)
);
CREATE INDEX IF NOT EXISTS idx_baskets_status ON baskets(status);
CREATE INDEX IF NOT EXISTS idx_baskets_event ON baskets(event_id);
CREATE TABLE IF NOT EXISTS fills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
basket_id TEXT NOT NULL,
token_id TEXT NOT NULL,
side TEXT NOT NULL,
price TEXT NOT NULL,
size TEXT NOT NULL,
fee_usd TEXT NOT NULL,
filled_at TEXT NOT NULL,
FOREIGN KEY (basket_id) REFERENCES baskets(id)
);
CREATE INDEX IF NOT EXISTS idx_fills_basket ON fills(basket_id);
CREATE TABLE IF NOT EXISTS live_orders (
id TEXT PRIMARY KEY,
basket_id TEXT NOT NULL,
token_id TEXT NOT NULL,
side TEXT NOT NULL,
price TEXT NOT NULL,
size TEXT NOT NULL,
order_type TEXT NOT NULL,
status TEXT NOT NULL,
clob_order_id TEXT,
tx_hash TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_live_orders_basket ON live_orders(basket_id);
CREATE TABLE IF NOT EXISTS resolutions (
event_id TEXT PRIMARY KEY,
winning_outcome_token_id TEXT,
resolved_at TEXT NOT NULL,
source TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS denylist (
event_id TEXT PRIMARY KEY,
reason TEXT NOT NULL,
added_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS daily_pnl (
date TEXT PRIMARY KEY,
paper_pnl_usd TEXT NOT NULL DEFAULT '0',
live_pnl_usd TEXT NOT NULL DEFAULT '0',
baskets_opened INTEGER NOT NULL DEFAULT 0,
baskets_redeemed INTEGER NOT NULL DEFAULT 0
);
"""
async def init_db(db_path: Path | None = None) -> None:
path = db_path or settings.db_path
path.parent.mkdir(parents=True, exist_ok=True)
async with aiosqlite.connect(path) as conn:
await conn.execute("PRAGMA journal_mode=WAL")
await conn.execute("PRAGMA foreign_keys=ON")
await conn.executescript(SCHEMA_SQL)
await conn.commit()
@asynccontextmanager
async def db_conn(db_path: Path | None = None) -> AsyncIterator[aiosqlite.Connection]:
path = db_path or settings.db_path
async with aiosqlite.connect(path) as conn:
await conn.execute("PRAGMA journal_mode=WAL")
await conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = aiosqlite.Row
yield conn
View File
+22
View File
@@ -0,0 +1,22 @@
"""Executor protocol.
Paper and live executors share the same surface so the rest of the engine is
mode-agnostic. The Opportunity -> Basket lifecycle is identical until the
very bottom of the stack, where one simulates and the other signs.
"""
from __future__ import annotations
from typing import Protocol
from ..models import Basket, Opportunity
class Executor(Protocol):
async def execute(self, opp: Opportunity) -> Basket | None:
"""Try to open a basket from an opportunity.
Returns the persisted basket on success (even for a `failed` basket —
that still got persisted for forensics). Returns None if the executor
rejected the opportunity before touching storage (e.g. risk gate).
"""
...
+353
View File
@@ -0,0 +1,353 @@
"""Live executor — deferred behind MODE=live.
Same `Executor` surface as PaperExecutor. Signs EIP-712 orders via
py-clob-client, submits FAK across all legs in parallel, aborts + unwinds on
partial fill, and calls NegRiskAdapter.redeemPositions once a complete set is
held.
Status: functional skeleton. The signing + order submission path is wired up,
but each side-effect is gated by a `dry_run` flag so nothing is broadcast until
the operator explicitly flips it. The risk gate is enforced here; it's the
last thing between an Opportunity and real capital.
See docs/api/order-signing.md and docs/api/negrisk.md for wire details.
"""
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from loguru import logger
from ..book.l2 import BookRegistry
from ..config import Mode, settings
from ..db import db_conn
from ..models import Basket, BasketStatus, Fill, Opportunity, OrderType, Side
@dataclass(slots=True)
class RiskLimits:
max_basket_usd: Decimal
max_open_baskets: int
max_open_baskets_per_event: int
daily_loss_stop_usd: Decimal
kill_switch_file: Path
@classmethod
def from_settings(cls) -> RiskLimits:
return cls(
max_basket_usd=settings.max_basket_usd,
max_open_baskets=settings.max_open_baskets,
max_open_baskets_per_event=settings.max_open_baskets_per_event,
daily_loss_stop_usd=settings.daily_loss_stop_usd,
kill_switch_file=settings.kill_switch_file,
)
class RiskDenied(Exception):
"""Raised when a risk gate refuses an opportunity."""
async def risk_gate(opp: Opportunity, limits: RiskLimits) -> None:
"""Apply hard caps. Raises RiskDenied with a reason if any cap is hit."""
if limits.kill_switch_file.exists():
raise RiskDenied(f"kill switch present: {limits.kill_switch_file}")
cost = opp.sum_vwap_asks * opp.max_baskets
if cost > limits.max_basket_usd:
raise RiskDenied(f"basket cost ${cost} > max ${limits.max_basket_usd}")
today = datetime.now(UTC).date().isoformat()
async with db_conn() as conn:
cursor = await conn.execute(
"""
SELECT COUNT(*) FROM baskets
WHERE is_paper=0 AND status IN (?, ?, ?)
""",
(
BasketStatus.OPEN.value,
BasketStatus.PARTIAL.value,
BasketStatus.PENDING_RESOLUTION.value,
),
)
(open_global,) = await cursor.fetchone()
if open_global >= limits.max_open_baskets:
raise RiskDenied(f"{open_global} live baskets already open")
cursor = await conn.execute(
"""
SELECT COUNT(*) FROM baskets
WHERE event_id=? AND is_paper=0 AND status IN (?, ?, ?)
""",
(
opp.event_id,
BasketStatus.OPEN.value,
BasketStatus.PARTIAL.value,
BasketStatus.PENDING_RESOLUTION.value,
),
)
(open_per_event,) = await cursor.fetchone()
if open_per_event >= limits.max_open_baskets_per_event:
raise RiskDenied(f"event {opp.event_id} already has {open_per_event} open")
cursor = await conn.execute(
"SELECT live_pnl_usd FROM daily_pnl WHERE date=?", (today,)
)
row = await cursor.fetchone()
if row is not None:
pnl = Decimal(row[0])
if pnl <= -limits.daily_loss_stop_usd:
raise RiskDenied(f"daily loss stop hit: pnl={pnl}")
class LiveExecutor:
"""Signs and submits orders. Requires MODE=live and wallet credentials."""
def __init__(
self,
*,
books: BookRegistry,
limits: RiskLimits | None = None,
dry_run: bool = True,
) -> None:
if settings.mode != Mode.LIVE:
raise RuntimeError(
"LiveExecutor instantiated but ARB_MODE is not live — refusing."
)
settings.require_live_credentials()
self._books = books
self._limits = limits or RiskLimits.from_settings()
self._dry_run = dry_run
self._clob = None # py_clob_client.ClobClient, lazy-init
def _ensure_clob(self):
if self._clob is not None:
return self._clob
# Imported lazily so paper-mode users don't need py-clob-client installed.
from py_clob_client.client import ClobClient
from py_clob_client.clob_types import ApiCreds
pk = settings.private_key.get_secret_value() if settings.private_key else None
if pk is None:
raise RuntimeError("ARB_PRIVATE_KEY is required for live mode")
creds = None
if settings.api_key and settings.api_secret and settings.api_passphrase:
creds = ApiCreds(
api_key=settings.api_key.get_secret_value(),
api_secret=settings.api_secret.get_secret_value(),
api_passphrase=settings.api_passphrase.get_secret_value(),
)
self._clob = ClobClient(
host=settings.clob_host,
key=pk,
chain_id=137,
signature_type=settings.signature_type,
funder=settings.funder_address,
creds=creds,
)
if creds is None:
self._clob.set_api_creds(self._clob.create_or_derive_api_creds())
return self._clob
async def execute(self, opp: Opportunity) -> Basket | None:
try:
await risk_gate(opp, self._limits)
except RiskDenied as exc:
logger.warning("risk denied opp {}: {}", opp.id, exc)
return None
now = datetime.now(UTC)
basket = Basket(
opportunity_id=opp.id,
event_id=opp.event_id,
is_paper=False,
created_at=now,
basket_count=opp.max_baskets,
total_cost_usd=Decimal(0),
status=BasketStatus.OPEN,
fills=[],
)
await self._persist_basket_open(opp, basket)
fills, total_cost, shortfall = await self._submit_parallel(opp, basket)
basket.fills = fills
basket.total_cost_usd = total_cost
if shortfall:
logger.error("partial fill detected on basket {}; unwinding", basket.id)
await self._unwind(basket, shortfall)
basket.status = BasketStatus.FAILED
else:
basket.status = BasketStatus.PENDING_RESOLUTION
await self._redeem_or_defer(basket, opp)
await self._persist_basket_final(basket)
return basket
async def _submit_parallel(
self, opp: Opportunity, basket: Basket
) -> tuple[list[Fill], Decimal, dict[str, Decimal]]:
tasks = [self._submit_leg(opp, leg, basket.id) for leg in opp.legs]
results = await asyncio.gather(*tasks, return_exceptions=True)
fills: list[Fill] = []
total_cost = Decimal(0)
shortfall: dict[str, Decimal] = {}
for leg, res in zip(opp.legs, results, strict=True):
if isinstance(res, Exception):
logger.error("leg {} raised: {}", leg.token_id, res)
shortfall[leg.token_id] = leg.size
continue
fill, short = res
fills.append(fill)
total_cost += fill.price * fill.size
if short > 0:
shortfall[leg.token_id] = short
return fills, total_cost, shortfall
async def _submit_leg(self, opp: Opportunity, leg, basket_id: str):
if self._dry_run:
logger.info("[dry_run] would FAK buy token={} price={} size={}",
leg.token_id, leg.vwap_price, leg.size)
fill = Fill(
token_id=leg.token_id, side=Side.BUY,
price=leg.vwap_price, size=leg.size,
fee_usd=Decimal(0), filled_at=datetime.now(UTC),
)
return fill, Decimal(0)
client = self._ensure_clob()
from py_clob_client.clob_types import OrderArgs
from py_clob_client.clob_types import OrderType as ClobOrderType
args = OrderArgs(
token_id=leg.token_id,
price=float(leg.vwap_price),
size=float(leg.size),
side="BUY",
)
# neg_risk=True is critical — routes to NegRiskCtfExchange.
signed = await asyncio.to_thread(
client.create_order, args, options={"neg_risk": True}
)
resp = await asyncio.to_thread(
client.post_order, signed, ClobOrderType.FAK
)
await self._persist_live_order(basket_id, leg, resp)
filled_size = Decimal(str(resp.get("making_amount") or resp.get("size_matched") or 0))
price = Decimal(str(resp.get("price") or leg.vwap_price))
short = leg.size - filled_size
fill = Fill(
token_id=leg.token_id, side=Side.BUY,
price=price, size=filled_size,
fee_usd=Decimal(str(resp.get("fee") or 0)),
filled_at=datetime.now(UTC),
)
return fill, max(short, Decimal(0))
async def _unwind(self, basket: Basket, shortfall: dict[str, Decimal]) -> None:
"""Sell any legs we over-filled relative to the shortfalled ones."""
short_legs = set(shortfall.keys())
for fill in basket.fills:
if fill.token_id in short_legs or fill.size <= 0:
continue
if self._dry_run:
logger.info("[dry_run] would market-sell token={} size={}",
fill.token_id, fill.size)
continue
client = self._ensure_clob()
from py_clob_client.clob_types import OrderArgs
from py_clob_client.clob_types import OrderType as ClobOrderType
args = OrderArgs(
token_id=fill.token_id,
price=0.0, # market
size=float(fill.size),
side="SELL",
)
signed = await asyncio.to_thread(
client.create_order, args, options={"neg_risk": True}
)
await asyncio.to_thread(client.post_order, signed, ClobOrderType.FAK)
async def _redeem_or_defer(self, basket: Basket, opp: Opportunity) -> None:
"""Once the full YES set is held, call NegRiskAdapter.redeemPositions.
Deferred (no-op) in MVP — the patient path is to wait for UMA and
call redeem from a separate resolution worker. This keeps the hot
path small and avoids gas on every successful basket.
"""
logger.info("basket {} pending resolution; redeem deferred to watcher", basket.id)
async def _persist_basket_open(self, opp: Opportunity, basket: Basket) -> None:
legs_json = json.dumps([leg.model_dump(mode="json") for leg in opp.legs])
async with db_conn() as conn:
await conn.execute(
"""
INSERT OR IGNORE INTO opportunities
(id, detected_at, event_id, event_title, sum_vwap_asks,
net_edge_bps, max_baskets, expected_profit_usd, legs_json, acted_on)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
""",
(
opp.id, opp.detected_at.isoformat(), opp.event_id, opp.event_title,
str(opp.sum_vwap_asks), opp.net_edge_bps, str(opp.max_baskets),
str(opp.expected_profit_usd), legs_json,
),
)
await conn.execute(
"""
INSERT INTO baskets
(id, opportunity_id, event_id, is_paper, created_at, basket_count,
total_cost_usd, status)
VALUES (?, ?, ?, 0, ?, ?, ?, ?)
""",
(basket.id, basket.opportunity_id, basket.event_id,
basket.created_at.isoformat(), str(basket.basket_count),
str(basket.total_cost_usd), basket.status.value),
)
await conn.commit()
async def _persist_basket_final(self, basket: Basket) -> None:
async with db_conn() as conn:
await conn.execute(
"""
UPDATE baskets SET total_cost_usd=?, status=? WHERE id=?
""",
(str(basket.total_cost_usd), basket.status.value, basket.id),
)
for fill in basket.fills:
await conn.execute(
"""
INSERT INTO fills
(basket_id, token_id, side, price, size, fee_usd, filled_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(basket.id, fill.token_id, fill.side.value, str(fill.price),
str(fill.size), str(fill.fee_usd), fill.filled_at.isoformat()),
)
await conn.commit()
async def _persist_live_order(self, basket_id: str, leg, resp: dict) -> None:
now = datetime.now(UTC).isoformat()
async with db_conn() as conn:
await conn.execute(
"""
INSERT INTO live_orders
(id, basket_id, token_id, side, price, size, order_type, status,
clob_order_id, tx_hash, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(resp.get("orderId") or resp.get("id") or f"{basket_id}-{leg.token_id}"),
basket_id, leg.token_id, Side.BUY.value,
str(leg.vwap_price), str(leg.size),
OrderType.FAK.value,
str(resp.get("status") or "submitted"),
resp.get("orderId") or resp.get("id"),
resp.get("transactionHash"),
now, now,
),
)
await conn.commit()
+87
View File
@@ -0,0 +1,87 @@
"""Scan loop: book updates -> engine.evaluate -> executor.execute.
One small glue function so the CLI and tests can both spin up the full
pipeline. Keeps the engine/executor decoupled — either side is swappable.
"""
from __future__ import annotations
import asyncio
from loguru import logger
from ..book.l2 import BookRegistry
from ..db import db_conn
from .executor import Executor
from .opportunity import EventIndex, OpportunityEngine
async def hydrate_event_index(index: EventIndex) -> int:
"""Load all active negRisk events from SQLite into the in-memory index."""
from ..models import Event, Outcome
async with db_conn() as conn:
cur = await conn.execute(
"""
SELECT id, slug, title, is_neg_risk, end_date
FROM events
WHERE active=1
"""
)
event_rows = await cur.fetchall()
count = 0
for row in event_rows:
cur = await conn.execute(
"""
SELECT token_id, name, outcome_index
FROM outcomes WHERE event_id=? ORDER BY outcome_index
""",
(row[0],),
)
outs = await cur.fetchall()
if len(outs) < 2:
continue
from datetime import datetime
end_date = None
if row[4]:
try:
end_date = datetime.fromisoformat(row[4])
except ValueError:
end_date = None
ev = Event(
id=row[0],
slug=row[1],
title=row[2],
is_neg_risk=bool(row[3]),
end_date=end_date,
outcomes=tuple(
Outcome(token_id=o[0], name=o[1], outcome_index=o[2]) for o in outs
),
)
index.upsert(ev)
count += 1
return count
async def run_scan_loop(
*,
books: BookRegistry,
index: EventIndex,
engine: OpportunityEngine,
executor: Executor,
) -> None:
"""Drive engine + executor off the book registry's update stream."""
engine_task = asyncio.create_task(engine.run(), name="engine.run")
logger.info("scan loop started ({} events hydrated)", len(index.by_event_id))
try:
async for opp in engine.opportunities():
try:
await executor.execute(opp)
except Exception as exc:
logger.exception("executor failed on opp {}: {}", opp.id, exc)
finally:
engine_task.cancel()
try:
await engine_task
except asyncio.CancelledError:
pass
+198
View File
@@ -0,0 +1,198 @@
"""NegRisk opportunity engine.
On every book update for a token that belongs to a known event, recompute the
sum of VWAP best-asks across all outcomes of that event and emit an
`Opportunity` when the depth-clipped net edge exceeds the threshold.
The math deliberately walks the book rather than trusting top-of-book — the
executable edge on a 100-share basket is often smaller than the quoted top.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from datetime import UTC, datetime
from decimal import Decimal
from loguru import logger
from ..book.l2 import BookRegistry, BookUpdate, LiveBook
from ..config import settings
from ..models import Event, Opportunity, OpportunityLeg
BPS = Decimal(10_000)
@dataclass(frozen=True, slots=True)
class EngineConfig:
min_net_edge_bps: int
fees_per_share_usd: Decimal
gas_per_basket_usd: Decimal
max_basket_usd: Decimal
min_basket_count: Decimal = Decimal(1)
size_grid: tuple[Decimal, ...] = (
Decimal(10),
Decimal(25),
Decimal(50),
Decimal(100),
Decimal(250),
Decimal(500),
Decimal(1000),
)
@classmethod
def from_settings(cls) -> EngineConfig:
return cls(
min_net_edge_bps=settings.min_net_edge_bps,
# CLOB taker fee is typically 0 on Polymarket; keep a hook for non-zero.
fees_per_share_usd=Decimal("0.0"),
# Approx sum of gas for split/buy legs + redeem on Polygon, USD-denominated.
# Sized conservatively at ~$0.10 until we wire a real gas oracle.
gas_per_basket_usd=Decimal("0.10"),
max_basket_usd=settings.max_basket_usd,
)
@dataclass
class EventIndex:
"""Registry mapping token_id -> Event so engine can look up siblings."""
by_event_id: dict[str, Event] = field(default_factory=dict)
by_token_id: dict[str, str] = field(default_factory=dict)
def upsert(self, event: Event) -> None:
self.by_event_id[event.id] = event
for o in event.outcomes:
self.by_token_id[o.token_id] = event.id
def event_for_token(self, token_id: str) -> Event | None:
eid = self.by_token_id.get(token_id)
return self.by_event_id.get(eid) if eid else None
class OpportunityEngine:
def __init__(
self,
*,
books: BookRegistry,
index: EventIndex,
config: EngineConfig | None = None,
) -> None:
self._books = books
self._index = index
self._config = config or EngineConfig.from_settings()
self._out: asyncio.Queue[Opportunity] = asyncio.Queue(maxsize=256)
@property
def config(self) -> EngineConfig:
return self._config
async def run(self) -> None:
async for update in self._books.updates():
try:
self._handle(update)
except Exception as exc:
logger.exception("engine handler failed: {}", exc)
def _handle(self, update: BookUpdate) -> None:
event = self._index.event_for_token(update.token_id)
if event is None:
return
opp = self.evaluate(event)
if opp is not None:
self._out.put_nowait(opp)
async def opportunities(self) -> AsyncIterator[Opportunity]:
while True:
yield await self._out.get()
def evaluate(self, event: Event) -> Opportunity | None:
legs_books: list[tuple[int, str, str, LiveBook]] = []
for o in event.outcomes:
book = self._books.get(o.token_id)
if book is None or not book.asks:
return None
legs_books.append((o.outcome_index, o.token_id, o.name, book))
best_opp: Opportunity | None = None
best_profit = Decimal("-1")
for candidate_k in self._candidate_sizes(legs_books):
legs, cost_sum = self._walk_legs(legs_books, candidate_k)
if legs is None:
continue
gross = Decimal(1) * candidate_k - cost_sum # per-basket gross = 1 - Σ vwap
gross_per_basket = gross / candidate_k
n = Decimal(len(legs))
fee_cost_per_basket = self._config.fees_per_share_usd * n
gas_amortized = self._config.gas_per_basket_usd / candidate_k
net_per_basket = gross_per_basket - fee_cost_per_basket - gas_amortized
if net_per_basket <= 0:
continue
bps = int((net_per_basket / Decimal(1)) * BPS)
if bps < self._config.min_net_edge_bps:
continue
expected_profit = net_per_basket * candidate_k
if expected_profit <= best_profit:
continue
candidate = Opportunity.from_legs(
detected_at=datetime.now(UTC),
event=event,
legs=tuple(legs),
fees_per_share=self._config.fees_per_share_usd,
gas_per_basket_usd=gas_amortized,
max_baskets=candidate_k,
)
best_opp = candidate
best_profit = expected_profit
return best_opp
def _candidate_sizes(
self, legs_books: list[tuple[int, str, str, LiveBook]]
) -> list[Decimal]:
depth = min(sum(lb.asks.values()) for *_, lb in legs_books)
if depth <= 0:
return []
est_cost_per_share = sum(
(lb.asks.keys()[0] for *_, lb in legs_books), Decimal(0)
)
budget_cap = (
self._config.max_basket_usd / est_cost_per_share
if est_cost_per_share > 0
else depth
)
ceiling = min(depth, budget_cap)
if ceiling < self._config.min_basket_count:
return []
candidates = [s for s in self._config.size_grid if s <= ceiling]
if not candidates or candidates[-1] != ceiling:
candidates.append(ceiling)
return candidates
def _walk_legs(
self,
legs_books: list[tuple[int, str, str, LiveBook]],
size: Decimal,
) -> tuple[list[OpportunityLeg] | None, Decimal]:
legs: list[OpportunityLeg] = []
total_cost = Decimal(0)
for idx, token_id, name, book in legs_books:
res = book.vwap_buy(size)
if res is None:
return None, Decimal(0)
vwap, filled, levels = res
if filled < size:
return None, Decimal(0)
total_cost += vwap * size
legs.append(
OpportunityLeg(
token_id=token_id,
outcome_name=name,
outcome_index=idx,
vwap_price=vwap,
size=size,
levels_consumed=levels,
)
)
return legs, total_cost
+269
View File
@@ -0,0 +1,269 @@
"""Paper-fill executor.
When an opportunity arrives, snapshot the book, wait `paper_latency_ms` to
model getting beaten by faster bots, and only fill against levels that survive
that delay. The simulator biases PnL *downward* relative to naive "fill at
observation time" paper trading.
Persistence: writes one `baskets` row (is_paper=1) plus one `fills` row per leg.
Status transitions:
- detected -> open (created, legs in flight)
- all legs filled at size -> pending_resolution
- any leg short -> failed (persisted for forensics; no resolution step)
"""
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal
from loguru import logger
from ..book.l2 import BookRegistry, LiveBook
from ..config import settings
from ..db import db_conn
from ..models import Basket, BasketStatus, Fill, Opportunity, Side
@dataclass(slots=True)
class PaperFillResult:
filled: Decimal
vwap_price: Decimal
levels_consumed: int
def simulate_leg_fill(book: LiveBook, target_size: Decimal) -> PaperFillResult:
"""Walk `book.asks` at the *current* moment and fill up to `target_size`.
Returns the actually-filled size (may be < target_size if depth vanished).
"""
if target_size <= 0 or not book.asks:
return PaperFillResult(Decimal(0), Decimal(0), 0)
remaining = target_size
cost = Decimal(0)
filled = Decimal(0)
levels = 0
for price in list(book.asks.keys()):
size = book.asks.get(price, Decimal(0))
if size <= 0:
continue
take = min(remaining, size)
cost += take * price
filled += take
levels += 1
remaining -= take
if remaining <= 0:
break
vwap = (cost / filled) if filled > 0 else Decimal(0)
return PaperFillResult(filled, vwap, levels)
class PaperExecutor:
"""Runs fill simulations + persistence for paper baskets."""
def __init__(
self,
*,
books: BookRegistry,
latency_ms: int | None = None,
fee_rate: Decimal = Decimal(0),
) -> None:
self._books = books
self._latency_ms = latency_ms if latency_ms is not None else settings.paper_latency_ms
self._fee_rate = fee_rate
@property
def latency_ms(self) -> int:
return self._latency_ms
async def execute(self, opp: Opportunity) -> Basket | None:
await asyncio.sleep(self._latency_ms / 1000.0)
return await self._simulate_and_persist(opp)
async def execute_now(self, opp: Opportunity) -> Basket | None:
"""Skip the sleep — used by tests that want deterministic fills."""
return await self._simulate_and_persist(opp)
async def _simulate_and_persist(self, opp: Opportunity) -> Basket | None:
target = opp.max_baskets
if target <= 0:
return None
fills: list[Fill] = []
total_cost = Decimal(0)
short_legs = 0
min_filled: Decimal | None = None
now = datetime.now(UTC)
for leg in opp.legs:
book = self._books.get(leg.token_id)
if book is None:
short_legs += 1
continue
result = simulate_leg_fill(book, target)
if result.filled < target:
short_legs += 1
if min_filled is None or result.filled < min_filled:
min_filled = result.filled
fee = result.vwap_price * result.filled * self._fee_rate
fills.append(
Fill(
token_id=leg.token_id,
side=Side.BUY,
price=result.vwap_price,
size=result.filled,
fee_usd=fee,
filled_at=now,
)
)
total_cost += result.vwap_price * result.filled + fee
basket_count = min_filled if min_filled is not None else Decimal(0)
if short_legs > 0 or basket_count <= 0:
status = BasketStatus.FAILED
else:
status = BasketStatus.PENDING_RESOLUTION
basket = Basket(
opportunity_id=opp.id,
event_id=opp.event_id,
is_paper=True,
created_at=now,
basket_count=basket_count,
total_cost_usd=total_cost,
status=status,
fills=fills,
)
await self._persist(opp, basket)
logger.info(
"paper basket {} status={} count={} cost={}",
basket.id,
basket.status.value,
basket.basket_count,
basket.total_cost_usd,
)
return basket
async def _persist(self, opp: Opportunity, basket: Basket) -> None:
legs_json = json.dumps([leg.model_dump(mode="json") for leg in opp.legs])
async with db_conn() as conn:
await conn.execute(
"""
INSERT OR IGNORE INTO opportunities
(id, detected_at, event_id, event_title, sum_vwap_asks,
net_edge_bps, max_baskets, expected_profit_usd, legs_json, acted_on)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
""",
(
opp.id,
opp.detected_at.isoformat(),
opp.event_id,
opp.event_title,
str(opp.sum_vwap_asks),
opp.net_edge_bps,
str(opp.max_baskets),
str(opp.expected_profit_usd),
legs_json,
),
)
await conn.execute(
"""
INSERT INTO baskets
(id, opportunity_id, event_id, is_paper, created_at, basket_count,
total_cost_usd, status)
VALUES (?, ?, ?, 1, ?, ?, ?, ?)
""",
(
basket.id,
basket.opportunity_id,
basket.event_id,
basket.created_at.isoformat(),
str(basket.basket_count),
str(basket.total_cost_usd),
basket.status.value,
),
)
for fill in basket.fills:
await conn.execute(
"""
INSERT INTO fills
(basket_id, token_id, side, price, size, fee_usd, filled_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
basket.id,
fill.token_id,
fill.side.value,
str(fill.price),
str(fill.size),
str(fill.fee_usd),
fill.filled_at.isoformat(),
),
)
await conn.commit()
async def mark_resolution(
event_id: str,
*,
winning_token_id: str | None,
resolved_at: datetime | None = None,
source: str = "manual",
) -> int:
"""Apply a resolution to all open paper baskets for the event.
Updates `resolutions` row, flips matching baskets to redeemed/invalid,
and writes realized PnL. Returns number of baskets updated.
"""
resolved_at = resolved_at or datetime.now(UTC)
async with db_conn() as conn:
await conn.execute(
"""
INSERT INTO resolutions (event_id, winning_outcome_token_id, resolved_at, source)
VALUES (?, ?, ?, ?)
ON CONFLICT(event_id) DO UPDATE SET
winning_outcome_token_id=excluded.winning_outcome_token_id,
resolved_at=excluded.resolved_at,
source=excluded.source
""",
(event_id, winning_token_id, resolved_at.isoformat(), source),
)
cursor = await conn.execute(
"""
SELECT id, basket_count, total_cost_usd, status
FROM baskets
WHERE event_id=? AND is_paper=1 AND status=?
""",
(event_id, BasketStatus.PENDING_RESOLUTION.value),
)
rows = await cursor.fetchall()
updated = 0
for row in rows:
basket_id = row[0]
basket_count = Decimal(row[1])
cost = Decimal(row[2])
if winning_token_id is None:
payout = Decimal(0)
new_status = BasketStatus.INVALID.value
else:
payout = basket_count * Decimal(1)
new_status = BasketStatus.REDEEMED.value
pnl = payout - cost
await conn.execute(
"""
UPDATE baskets
SET status=?, redeemed_at=?, redeemed_payout_usd=?, realized_pnl_usd=?
WHERE id=?
""",
(
new_status,
resolved_at.isoformat(),
str(payout),
str(pnl),
basket_id,
),
)
updated += 1
await conn.commit()
return updated
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
import sys
from loguru import logger
from .config import settings
def configure_logging() -> None:
logger.remove()
logger.add(
sys.stderr,
level=settings.log_level,
format=(
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> "
"| <level>{level: <8}</level> "
"| <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> "
"- <level>{message}</level>"
),
backtrace=False,
diagnose=False,
)
logger.add(
"logs/arbitrage.jsonl",
level="DEBUG",
rotation="1 day",
retention="7 days",
serialize=True,
)
+193
View File
@@ -0,0 +1,193 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Self
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field
def _uuid() -> str:
return uuid4().hex
class Side(str, Enum):
BUY = "BUY"
SELL = "SELL"
class OrderType(str, Enum):
GTC = "GTC"
FAK = "FAK"
FOK = "FOK"
GTD = "GTD"
class BasketStatus(str, Enum):
OPEN = "open"
PARTIAL = "partial"
FAILED = "failed"
PENDING_RESOLUTION = "pending_resolution"
REDEEMED = "redeemed"
INVALID = "invalid"
class Outcome(BaseModel):
model_config = ConfigDict(frozen=True)
token_id: str
name: str
outcome_index: int
class Event(BaseModel):
model_config = ConfigDict(frozen=True)
id: str
slug: str
title: str
is_neg_risk: bool
end_date: datetime | None
outcomes: tuple[Outcome, ...]
@property
def n_outcomes(self) -> int:
return len(self.outcomes)
class BookLevel(BaseModel):
model_config = ConfigDict(frozen=True)
price: Decimal
size: Decimal
class Book(BaseModel):
"""L2 order book for a single token. Bids descending, asks ascending."""
token_id: str
bids: list[BookLevel] = Field(default_factory=list)
asks: list[BookLevel] = Field(default_factory=list)
sequence: int = 0
updated_at: datetime
@property
def best_bid(self) -> BookLevel | None:
return self.bids[0] if self.bids else None
@property
def best_ask(self) -> BookLevel | None:
return self.asks[0] if self.asks else None
def vwap_buy(self, target_size: Decimal) -> tuple[Decimal, Decimal] | None:
"""Walk asks to fill `target_size`. Returns (vwap_price, filled_size).
If the book can't fill the full size, returns the partial fill."""
if target_size <= 0 or not self.asks:
return None
remaining = target_size
cost = Decimal(0)
filled = Decimal(0)
for level in self.asks:
take = min(remaining, level.size)
cost += take * level.price
filled += take
remaining -= take
if remaining <= 0:
break
if filled <= 0:
return None
return (cost / filled, filled)
class OpportunityLeg(BaseModel):
model_config = ConfigDict(frozen=True)
token_id: str
outcome_name: str
outcome_index: int
vwap_price: Decimal
size: Decimal
levels_consumed: int
class Opportunity(BaseModel):
"""A snapshot of edge math for one event at one moment."""
id: str = Field(default_factory=_uuid)
detected_at: datetime
event_id: str
event_title: str
legs: tuple[OpportunityLeg, ...]
sum_vwap_asks: Decimal
fees_per_share: Decimal
gas_per_basket_usd: Decimal
gross_edge_usd_per_basket: Decimal
net_edge_usd_per_basket: Decimal
net_edge_bps: int
max_baskets: Decimal
expected_profit_usd: Decimal
@classmethod
def from_legs(
cls,
*,
detected_at: datetime,
event: Event,
legs: tuple[OpportunityLeg, ...],
fees_per_share: Decimal,
gas_per_basket_usd: Decimal,
max_baskets: Decimal,
) -> Self:
sum_asks = sum((leg.vwap_price for leg in legs), Decimal(0))
gross_per_basket = Decimal(1) - sum_asks
n = Decimal(event.n_outcomes)
net_per_basket = gross_per_basket - (fees_per_share * n) - gas_per_basket_usd
net_bps = int((net_per_basket / Decimal(1)) * Decimal(10_000)) if net_per_basket else 0
expected = net_per_basket * max_baskets
return cls(
detected_at=detected_at,
event_id=event.id,
event_title=event.title,
legs=legs,
sum_vwap_asks=sum_asks,
fees_per_share=fees_per_share,
gas_per_basket_usd=gas_per_basket_usd,
gross_edge_usd_per_basket=gross_per_basket,
net_edge_usd_per_basket=net_per_basket,
net_edge_bps=net_bps,
max_baskets=max_baskets,
expected_profit_usd=expected,
)
class Fill(BaseModel):
token_id: str
side: Side
price: Decimal
size: Decimal
fee_usd: Decimal
filled_at: datetime
class Basket(BaseModel):
id: str = Field(default_factory=_uuid)
opportunity_id: str
event_id: str
is_paper: bool
created_at: datetime
basket_count: Decimal
total_cost_usd: Decimal
status: BasketStatus
fills: list[Fill] = Field(default_factory=list)
redeemed_at: datetime | None = None
redeemed_payout_usd: Decimal | None = None
realized_pnl_usd: Decimal | None = None
class Resolution(BaseModel):
event_id: str
winning_outcome_token_id: str | None
resolved_at: datetime
source: str
View File
View File
+184
View File
@@ -0,0 +1,184 @@
"""FastAPI + HTMX dashboard.
Single page. Server-rendered via Jinja. HTMX polls the fragment endpoints
every few seconds so there's no client-side state and no build step.
Endpoints:
GET / full page
GET /fragments/opportunities table of recent opportunities
GET /fragments/baskets table of open + recent baskets
GET /fragments/pnl paper pnl summary + mode indicator
POST /kill touch the kill switch file (immediate halt)
POST /unkill remove the kill switch file
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from decimal import Decimal
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from ..config import settings
from ..db import db_conn, init_db
TEMPLATE_DIR = Path(__file__).parent / "templates"
@asynccontextmanager
async def _lifespan(_: FastAPI) -> AsyncIterator[None]:
await init_db()
yield
def create_app() -> FastAPI:
app = FastAPI(title="arbitrage dashboard", lifespan=_lifespan)
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
# Python 3.14 + Jinja2 LRUCache regression: disable caching.
templates.env.cache = None
@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request, "index.html", {"mode": settings.mode.value}
)
@app.get("/fragments/opportunities", response_class=HTMLResponse)
async def opps(request: Request) -> HTMLResponse:
rows = await _recent_opportunities(limit=25)
return templates.TemplateResponse(
request, "fragments/opportunities.html", {"rows": rows}
)
@app.get("/fragments/baskets", response_class=HTMLResponse)
async def baskets(request: Request) -> HTMLResponse:
rows = await _recent_baskets(limit=25)
return templates.TemplateResponse(
request, "fragments/baskets.html", {"rows": rows}
)
@app.get("/fragments/pnl", response_class=HTMLResponse)
async def pnl(request: Request) -> HTMLResponse:
summary = await _paper_pnl_summary()
kill_active = settings.kill_switch_file.exists()
return templates.TemplateResponse(
request,
"fragments/pnl.html",
{
"mode": settings.mode.value,
"kill_active": kill_active,
**summary,
},
)
@app.post("/kill", response_class=HTMLResponse)
async def kill(request: Request) -> HTMLResponse:
settings.kill_switch_file.parent.mkdir(parents=True, exist_ok=True)
settings.kill_switch_file.touch(exist_ok=True)
return await pnl(request)
@app.post("/unkill", response_class=HTMLResponse)
async def unkill(request: Request) -> HTMLResponse:
path = settings.kill_switch_file
if path.exists():
path.unlink()
return await pnl(request)
return app
async def _recent_opportunities(limit: int) -> list[dict]:
async with db_conn() as conn:
cur = await conn.execute(
"""
SELECT detected_at, event_title, sum_vwap_asks, net_edge_bps,
max_baskets, expected_profit_usd, acted_on
FROM opportunities
ORDER BY detected_at DESC
LIMIT ?
""",
(limit,),
)
rows = await cur.fetchall()
return [
{
"detected_at": r[0],
"event_title": r[1],
"sum_vwap_asks": r[2],
"net_edge_bps": r[3],
"max_baskets": r[4],
"expected_profit_usd": r[5],
"acted_on": bool(r[6]),
}
for r in rows
]
async def _recent_baskets(limit: int) -> list[dict]:
async with db_conn() as conn:
cur = await conn.execute(
"""
SELECT b.id, b.created_at, e.title, b.is_paper, b.basket_count,
b.total_cost_usd, b.status, b.realized_pnl_usd
FROM baskets b
LEFT JOIN events e ON e.id = b.event_id
ORDER BY b.created_at DESC
LIMIT ?
""",
(limit,),
)
rows = await cur.fetchall()
return [
{
"id": r[0][:8],
"created_at": r[1],
"event_title": r[2] or "?",
"is_paper": bool(r[3]),
"basket_count": r[4],
"total_cost_usd": r[5],
"status": r[6],
"realized_pnl_usd": r[7],
}
for r in rows
]
async def _paper_pnl_summary() -> dict:
async with db_conn() as conn:
cur = await conn.execute(
"""
SELECT
COALESCE(SUM(CASE WHEN status='redeemed' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status='pending_resolution' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status='invalid' THEN 1 ELSE 0 END), 0)
FROM baskets WHERE is_paper=1
"""
)
counts = await cur.fetchone()
cur = await conn.execute(
"SELECT realized_pnl_usd FROM baskets "
"WHERE is_paper=1 AND realized_pnl_usd IS NOT NULL"
)
pnl_rows = await cur.fetchall()
total = Decimal(0)
for (val,) in pnl_rows:
if val is None:
continue
try:
total += Decimal(val)
except (ArithmeticError, ValueError):
pass
return {
"redeemed": counts[0],
"pending": counts[1],
"failed": counts[2],
"invalid": counts[3],
"realized_pnl_usd": f"{total:.4f}",
}
app = create_app()
@@ -0,0 +1,29 @@
<h2>baskets</h2>
<table>
<thead>
<tr>
<th>id</th>
<th>event</th>
<th>mode</th>
<th class="num">count</th>
<th class="num">cost</th>
<th>status</th>
<th class="num">pnl</th>
</tr>
</thead>
<tbody>
{% for b in rows %}
<tr>
<td>{{ b.id }}</td>
<td>{{ b.event_title }}</td>
<td>{{ "paper" if b.is_paper else "live" }}</td>
<td class="num">{{ b.basket_count }}</td>
<td class="num">{{ b.total_cost_usd }}</td>
<td><span class="badge {{ b.status }}">{{ b.status }}</span></td>
<td class="num {% if b.realized_pnl_usd and b.realized_pnl_usd.startswith('-') %}neg{% elif b.realized_pnl_usd %}pos{% endif %}">{{ b.realized_pnl_usd or '—' }}</td>
</tr>
{% else %}
<tr><td colspan="7" style="color:#8b949e;">no baskets yet</td></tr>
{% endfor %}
</tbody>
</table>
@@ -0,0 +1,29 @@
<h2>recent opportunities</h2>
<table>
<thead>
<tr>
<th>detected</th>
<th>event</th>
<th class="num">Σ asks</th>
<th class="num">net bps</th>
<th class="num">max baskets</th>
<th class="num">expected $</th>
<th>acted</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.detected_at }}</td>
<td>{{ r.event_title }}</td>
<td class="num">{{ r.sum_vwap_asks }}</td>
<td class="num {% if r.net_edge_bps > 0 %}pos{% else %}neg{% endif %}">{{ r.net_edge_bps }}</td>
<td class="num">{{ r.max_baskets }}</td>
<td class="num">{{ r.expected_profit_usd }}</td>
<td>{% if r.acted_on %}<span class="badge redeemed">yes</span>{% else %}<span class="badge">no</span>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="7" style="color:#8b949e;">no opportunities yet</td></tr>
{% endfor %}
</tbody>
</table>
@@ -0,0 +1,17 @@
<h2>paper pnl</h2>
<table>
<tr><th>realized pnl (usd)</th><td class="num {% if realized_pnl_usd.startswith('-') %}neg{% else %}pos{% endif %}">{{ realized_pnl_usd }}</td></tr>
<tr><th>redeemed</th><td class="num">{{ redeemed }}</td></tr>
<tr><th>pending resolution</th><td class="num">{{ pending }}</td></tr>
<tr><th>failed</th><td class="num">{{ failed }}</td></tr>
<tr><th>invalid</th><td class="num">{{ invalid }}</td></tr>
</table>
<p style="margin-top: 0.75rem;">
mode: <strong>{{ mode }}</strong>
{% if kill_active %}
&middot; <span class="badge failed">KILL SWITCH ACTIVE</span>
<button class="unkill" hx-post="/unkill" hx-target="#pnl" hx-swap="innerHTML">arm (remove kill)</button>
{% else %}
<button class="kill" hx-post="/kill" hx-target="#pnl" hx-swap="innerHTML">kill</button>
{% endif %}
</p>
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>arbitrage — {{ mode }}</title>
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
<style>
:root { color-scheme: dark; }
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
margin: 0; padding: 1.5rem; background: #0e1117; color: #e6edf3; }
h1 { font-size: 1.1rem; margin: 0 0 1rem; letter-spacing: 0.02em; }
h1 .mode { padding: 0.15em 0.5em; border-radius: 4px; font-size: 0.8em;
margin-left: 0.6em; background: #1f6feb; color: white; }
h1 .mode.live { background: #da3633; }
.grid { display: grid; gap: 1.25rem; grid-template-columns: 1fr; }
@media (min-width: 960px) { .grid { grid-template-columns: 1fr 1fr; } }
section { background: #161b22; border: 1px solid #30363d; border-radius: 6px;
padding: 1rem; }
section h2 { font-size: 0.85rem; margin: 0 0 0.75rem; text-transform: uppercase;
letter-spacing: 0.05em; color: #8b949e; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th, td { text-align: left; padding: 0.4rem 0.55rem; border-bottom: 1px solid #21262d; }
th { color: #8b949e; font-weight: 600; }
.num { font-variant-numeric: tabular-nums; text-align: right; }
.pos { color: #3fb950; }
.neg { color: #f85149; }
.badge { padding: 0.1em 0.45em; border-radius: 4px; font-size: 0.75em; background: #30363d; }
.badge.redeemed { background: #1f6f3e; }
.badge.failed { background: #8b1a1a; }
.badge.pending_resolution { background: #5a4200; }
.badge.invalid { background: #3b1f5a; }
button { background: #21262d; color: #e6edf3; border: 1px solid #30363d;
padding: 0.4em 0.9em; border-radius: 4px; cursor: pointer; }
button.kill { background: #8b1a1a; border-color: #da3633; }
button.unkill { background: #1f6f3e; border-color: #3fb950; }
</style>
</head>
<body>
<h1>arbitrage
<span class="mode {% if mode == 'live' %}live{% endif %}">{{ mode }}</span>
</h1>
<div class="grid">
<section id="pnl"
hx-get="/fragments/pnl" hx-trigger="load, every 3s" hx-swap="innerHTML">
loading...
</section>
<section id="baskets"
hx-get="/fragments/baskets" hx-trigger="load, every 3s" hx-swap="innerHTML">
loading...
</section>
<section id="opps" style="grid-column: 1 / -1;"
hx-get="/fragments/opportunities" hx-trigger="load, every 2s" hx-swap="innerHTML">
loading...
</section>
</div>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
services:
init:
build: .
command: ["init"]
environment:
- ARB_MODE=paper
- ARB_DB_PATH=/data/arbitrage.db
volumes:
- arb_data:/data
discover:
build: .
depends_on:
init:
condition: service_completed_successfully
command: ["discover", "--loop", "--interval", "180"]
environment:
- ARB_MODE=paper
- ARB_DB_PATH=/data/arbitrage.db
volumes:
- arb_data:/data
restart: unless-stopped
scan:
build: .
depends_on:
init:
condition: service_completed_successfully
command: ["scan"]
environment:
- ARB_MODE=paper
- ARB_DB_PATH=/data/arbitrage.db
volumes:
- arb_data:/data
restart: unless-stopped
web:
build: .
depends_on:
init:
condition: service_completed_successfully
command: ["web"]
environment:
- ARB_MODE=paper
- ARB_DB_PATH=/data/arbitrage.db
- ARB_WEB_HOST=0.0.0.0
- ARB_WEB_PORT=8000
volumes:
- arb_data:/data
ports:
- "8000:8000"
restart: unless-stopped
volumes:
arb_data:
+5
View File
@@ -0,0 +1,5 @@
# Prevent Jekyll from trying to process the site; we ship plain HTML/CSS/JS.
theme: null
plugins: []
include:
- assets
+487
View File
@@ -0,0 +1,487 @@
# Polymarket NegRisk Reference (Polygon mainnet)
Single-page reference for arbitraging categorical Polymarket events where
`Σ(best-ask of every outcome) < $1`. Covers the contracts, ABI, lifecycle, Gamma
API detection, ID derivation, a runnable web3.py snippet, and gotchas.
All sources cited inline. Last verified: April 2026.
---
## 1. Concept
Vanilla Polymarket markets use Gnosis's **Conditional Token Framework (CTF)**:
each binary market mints a YES and a NO ERC-1155 token, fully collateralized 1:1
by USDC.e. Splitting 1 USDC.e gives `1 YES + 1 NO`; merging the pair returns
1 USDC.e; after resolution the winning side redeems for 1 USDC.e each.
A **categorical event** ("Who wins the 2028 US Election?") is modeled as N
independent binary markets — one per candidate. Without negRisk these markets
are unconnected, which means a holder of `NO` on every candidate is locked up
even though, by construction, exactly one of them must resolve YES. NegRisk
fixes this: the **NegRiskAdapter** wraps the underlying CTF and adds a
`convertPositions` operation: 1 NO share in market *i* of an event can be
atomically converted into 1 YES share in **every other** market of that event.
That makes a complete set of YES tokens (one per outcome) economically
equivalent to $1 USDC.e and lets capital be freed early instead of waiting for
oracle resolution. This is the property the arb strategy exploits — when the
best-ask sum of every outcome's YES token is below $1, you can buy a complete
set, redeem (or convert+redeem), and lock in the spread.
([NegRisk overview](https://docs.polymarket.com/developers/neg-risk/overview),
[neg-risk-ctf-adapter README](https://github.com/Polymarket/neg-risk-ctf-adapter),
[ChainSecurity audit, Apr 2024](https://old.chainsecurity.com/wp-content/uploads/2024/04/ChainSecurity_Polymarket_NegRiskAdapter_audit.pdf))
---
## 2. Contract addresses (Polygon mainnet, chainId 137)
Source: [Polymarket Contract Addresses](https://docs.polymarket.com/resources/contract-addresses),
cross-checked on PolygonScan.
| Contract | Address | PolygonScan |
|---|---|---|
| **NegRiskAdapter** | `0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296` | [link](https://polygonscan.com/address/0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296) |
| **NegRiskCtfExchange** | `0xC5d563A36AE78145C45a50134d48A1215220f80a` | [link](https://polygonscan.com/address/0xc5d563a36ae78145c45a50134d48a1215220f80a) |
| **NegRiskFeeModule** | `0x78769D50Be1763ed1CA0D5E878D93f05aabff29e` | [link](https://polygonscan.com/address/0x78769d50be1763ed1ca0d5e878d93f05aabff29e) |
| **CTFExchange** (vanilla) | `0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E` | [link](https://polygonscan.com/address/0x4bfb41d5b3570defd03c39a9a4d8de6bd8b8982e) |
| **ConditionalTokens (CTF)** | `0x4D97DCd97eC945f40cF65F87097ACe5EA0476045` | [link](https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045) |
| **USDC.e (collateral)** | `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174` | [link](https://polygonscan.com/address/0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174) |
| **UmaCtfAdapter** (oracle) | `0x6A9D222616C90FcA5754cd1333cFD9b7fb6a4F74` | [link](https://polygonscan.com/address/0x6A9D222616C90FcA5754cd1333cFD9b7fb6a4F74) |
| **UMA Optimistic Oracle** | `0xCB1822859cEF82Cd2Eb4E6276C7916e692995130` | [link](https://polygonscan.com/address/0xCB1822859cEF82Cd2Eb4E6276C7916e692995130) |
> The collateral is **USDC.e** (the bridged PoS USDC), **not** native
> Circle-issued USDC (`0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359`). Confusing
> these two will silently break allowance checks. See Gotchas §8.
---
## 3. Key ABI signatures
The NegRiskAdapter exposes both vanilla CTF-shaped overloads (so it can act as
a drop-in `IConditionalTokens` proxy) and negRisk-specific entrypoints. Source:
[`NegRiskAdapter.sol`](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/src/NegRiskAdapter.sol),
[`docs/NegRiskAdapter.md`](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/docs/NegRiskAdapter.md).
### 3.1 Position management (call these on the NegRiskAdapter)
```solidity
// Mint a complete set: deposits `_amount` USDC.e, mints `_amount` of each YES & NO.
function splitPosition(bytes32 _conditionId, uint256 _amount) external;
// Burn a complete set (YES + NO of every outcome of the condition) for USDC.e.
function mergePositions(bytes32 _conditionId, uint256 _amount) external;
// After UMA resolution, redeem held outcome tokens for USDC.e payout.
// _amounts[i] is the amount of outcome-i token to burn.
function redeemPositions(bytes32 _conditionId, uint256[] calldata _amounts) external;
// negRisk-specific: convert NO shares in markets selected by `_indexSet`
// (a bitmask over the marketId's questions) into YES shares of the rest + collateral.
function convertPositions(bytes32 _marketId, uint256 _indexSet, uint256 _amount) external;
```
There are also legacy 5-arg overloads kept for `IConditionalTokens` API parity
(unused by clients in practice):
```solidity
function splitPosition(address _collateralToken, bytes32, bytes32 _conditionId,
uint256[] calldata, uint256 _amount) external;
function mergePositions(address _collateralToken, bytes32, bytes32 _conditionId,
uint256[] calldata, uint256 _amount) external;
```
### 3.2 ID lookups (view)
```solidity
function getConditionId(bytes32 _questionId) external view returns (bytes32);
function getPositionId(bytes32 _questionId, bool _outcome) external view returns (uint256);
function balanceOf(address _owner, uint256 _id) external view returns (uint256);
function balanceOfBatch(address[] memory _owners, uint256[] memory _ids)
external view returns (uint256[] memory);
```
### 3.3 Admin / oracle (you will not call these, but useful for tracing)
```solidity
function prepareMarket(uint256 _feeBips, bytes calldata _metadata) external returns (bytes32);
function prepareQuestion(bytes32 _marketId, bytes calldata _metadata) external returns (bytes32);
function reportOutcome(bytes32 _questionId, bool _outcome) external; // onlyOperator
```
### 3.4 Required ERC-20 / ERC-1155 approvals
Before any of the above, set:
```solidity
// USDC.e:
IERC20(USDC_E).approve(NegRiskAdapter, type(uint256).max);
// ERC-1155 outcome tokens (for merge / redeem / convert):
IConditionalTokens(CTF).setApprovalForAll(NegRiskAdapter, true);
```
(Source: [`NegRiskAdapter.sol`](https://raw.githubusercontent.com/Polymarket/neg-risk-ctf-adapter/main/src/NegRiskAdapter.sol))
---
## 4. End-to-end arb lifecycle
For a categorical event with N outcomes where `Σ best_ask_i < 1`:
1. **Approvals (one-time per wallet)**
- `USDC.e.approve(NegRiskAdapter, 2^256-1)`
- `ConditionalTokens.setApprovalForAll(NegRiskAdapter, true)`
- Approvals required for the **NegRiskCtfExchange** (`0xC5d5...80a`) for
trading: `USDC.e.approve(exchange, ...)` and
`ConditionalTokens.setApprovalForAll(exchange, true)`.
2. **Buy a complete set via the Exchange**
- Use the CLOB (`py-clob-client`, set `neg_risk=True` on the order options)
to lift the best ask of each of the N outcome tokens for `size` shares.
Total USDC.e spent ≈ `size * Σ best_ask_i`, which is < `size * $1`.
- Equivalent: hit each `clobTokenIds[YES]` from the Gamma `markets[]` array.
3. **Redeem on resolution OR free capital early**
- **Patient path**: wait for UMA to resolve the event and call
`NegRiskAdapter.redeemPositions(conditionId_winner, [size, 0])` on the
winning binary market. Payout = `size * 1 USDC.e`. Profit
= `size * (1 Σ best_ask_i)` minus gas and fees.
- **Capital-recycling path** (the negRisk superpower): once you hold one
YES of every outcome of the event, that bundle is economically `$1` per
unit. Rather than redeem on each binary, you can `convertPositions` to
consolidate, or simply burn the bundle: per the adapter, holding the
full YES set is interchangeable with USDC.e, so a `mergePositions` on
each conditionId (each binary has its own NO if you also hold it, or
use `convert`) returns USDC.e instantly without waiting for the oracle.
Practically: most arb bots redeem after resolution because acquiring a
full NO+YES pair on every binary defeats the point — you bought only the
YES legs for the discount.
4. **USDC.e arrives in your wallet.** Fees: NegRisk markets pay a small
protocol fee on conversion (defined by `_feeBips` at `prepareMarket`
time, paid to the Vault); redeem itself has no Polymarket fee.
---
## 5. Detecting negRisk markets via the Gamma API
Endpoint: `https://gamma-api.polymarket.com/events?...`
The two flags that matter on each `event` JSON object:
| JSON field | Type | Meaning |
|---|---|---|
| `negRisk` | bool | `true` → categorical event; outcomes are tied via the NegRiskAdapter. |
| `negRiskMarketID` | hex string (`bytes32`) | The shared `marketId` that links every binary in this event. Same value also appears on each child `markets[i].negRiskMarketID`. |
| `enableNegRisk` | bool | Set on a market when it can be added later as a new outcome (placeholder-capable). |
| `negRiskAugmented` | bool | Indicates the event has been augmented with such placeholder markets. |
The relevant fields inside each `markets[i]` element:
| JSON field | Use |
|---|---|
| `conditionId` (`bytes32`) | Pass to `redeemPositions` / `splitPosition`. |
| `questionID` (`bytes32`) | Source of `conditionId` via `getConditionId(questionID)`. |
| `clobTokenIds` | `[YES_tokenId, NO_tokenId]` as decimal strings. These are the ERC-1155 ids you reference to the CLOB order book. |
| `outcomePrices` | `["yes", "no"]` last-trade probabilities. Use `book` REST/WS for live best ask. |
Sample (trimmed) — `2026 FIFA World Cup Winner` event:
```json
{
"id": "12345",
"negRisk": true,
"negRiskMarketID": "0xb5c32a9acd39848acad4913ac4cd49c5de2afcc9d23a8a7ba2419375fab87400",
"markets": [
{
"questionID": "0x...",
"conditionId": "0x7976b8dbacf9077eb1453a62bcefd6ab2df199acd28aad276ff0d920d6992892",
"clobTokenIds": ["4394372887385518214471608448209527405727552777602031099972143344338178308080",
"112680630004798425069810935278212000865453267506345451433803052322987302357330"],
"outcomePrices": ["0.1715","0.8285"],
"negRiskMarketID": "0xb5c32a9acd39848acad4913ac4cd49c5de2afcc9d23a8a7ba2419375fab87400"
},
...
]
}
```
Sample query to surface candidates:
```python
import requests
events = requests.get(
"https://gamma-api.polymarket.com/events",
params={"closed": "false", "limit": 200, "order": "volume24hr",
"ascending": "false"},
timeout=15,
).json()
neg_risk_events = [e for e in events if e.get("negRisk")]
for e in neg_risk_events:
yes_asks = [float(m["outcomePrices"][0]) for m in e["markets"]]
if sum(yes_asks) < 0.99: # candidate; verify against live book
print(e["title"], sum(yes_asks))
```
(Source: live Gamma API response; field list cross-checked against
[`Polymarket/agents/agents/polymarket/gamma.py`](https://github.com/Polymarket/agents/blob/main/agents/polymarket/gamma.py)
and [docs.polymarket.com/developers/neg-risk/overview](https://docs.polymarket.com/developers/neg-risk/overview).)
---
## 6. How `tokenId` and `conditionId` are derived
NegRisk markets reuse the underlying CTF derivation rules but plug a
**WrappedCollateral** ERC-20 in place of raw USDC.e. That changes which
collateral address goes into `positionId` — the one number that frequently
trips up new integrators.
### 6.1 Vanilla CTF (used by non-negRisk binary markets)
```text
conditionId = keccak256( oracle ‖ questionId ‖ outcomeSlotCount )
collectionId = EC point-add of (parentCollectionId, hashToCurve(conditionId ‖ indexSet))
positionId = uint256( keccak256( collateralToken ‖ collectionId ) )
```
For a vanilla binary market: `oracle = UmaCtfAdapter`, `outcomeSlotCount = 2`,
`collateralToken = USDC.e`, `indexSet = 1` for YES and `2` for NO.
(Source: [CTHelpers.sol](https://raw.githubusercontent.com/Polymarket/neg-risk-ctf-adapter/main/src/libraries/CTHelpers.sol))
### 6.2 NegRisk markets (the difference)
For each outcome of a categorical event, NegRisk creates an independent
binary CTF condition, **but** with two changes:
1. The CTF `oracle` field is set to the **NegRiskAdapter address**
(`0xd91E…5296`) — not UmaCtfAdapter. The NegRiskAdapter is itself the
thing that calls `reportPayouts` upstream.
2. The `collateralToken` baked into `positionId` is the
**WrappedCollateral** ERC-20 (deployed by the adapter), not USDC.e. The
adapter holds USDC.e and mints/burns wrapper tokens 1:1 against it.
Practically:
- `marketId` (the negRisk-level grouping) =
`keccak256(operator ‖ feeBips ‖ metadata ‖ nonce)` — assigned at
`prepareMarket` time and is what `negRiskMarketID` in Gamma exposes.
- `questionId` for the i-th binary in the event =
`keccak256(marketId ‖ i)` (the index byte is the `_questionIndex`),
which keeps all questions of a categorical event derivable from the
single marketId.
- `conditionId = NegRiskAdapter.getConditionId(questionId)`
= `keccak256(NegRiskAdapter ‖ questionId ‖ 2)`.
- `positionId(YES) = NegRiskAdapter.getPositionId(questionId, true)`
`positionId(NO) = NegRiskAdapter.getPositionId(questionId, false)`
these match the decimal `clobTokenIds` returned by the Gamma API.
> **In practice you do not recompute these.** Pull `conditionId` and
> `clobTokenIds` straight from Gamma; only call `getPositionId` /
> `getConditionId` if you want to verify against on-chain truth.
(Source: [`NegRiskAdapter.sol`](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/src/NegRiskAdapter.sol),
[`MarketStateLib`](https://github.com/Polymarket/neg-risk-ctf-adapter/tree/main/src/libraries),
ChainSecurity audit §2.1)
---
## 7. End-to-end Python (web3.py) — approve + simulate redeem
Self-contained, structurally complete. Uses placeholder `0x...` for the
private key only. The redeem call is built but NOT broadcast — `call()` runs
it as an `eth_call` simulation.
```python
"""
Polymarket NegRisk arb — approval + redeem simulation on Polygon.
Requires: web3>=6.20, requests
"""
import os
import requests
from web3 import Web3
from web3.middleware import ExtraDataToPOAMiddleware # PoA chain (Polygon)
# ---- 1. Connect ----------------------------------------------------------
RPC_URL = os.getenv("POLYGON_RPC", "https://polygon-rpc.com")
w3 = Web3(Web3.HTTPProvider(RPC_URL))
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
assert w3.is_connected(), "RPC down"
# ---- 2. Addresses (Polygon mainnet) --------------------------------------
NEG_RISK_ADAPTER = Web3.to_checksum_address("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296")
NEG_RISK_EXCHANGE = Web3.to_checksum_address("0xC5d563A36AE78145C45a50134d48A1215220f80a")
CTF = Web3.to_checksum_address("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045")
USDC_E = Web3.to_checksum_address("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
# ---- 3. Wallet (placeholder) --------------------------------------------
PRIVATE_KEY = os.getenv("PK", "0x" + "11" * 32) # placeholder
acct = w3.eth.account.from_key(PRIVATE_KEY)
ME = acct.address
# ---- 4. Minimal ABIs -----------------------------------------------------
ERC20_ABI = [
{"name":"approve","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],
"outputs":[{"type":"bool"}]},
{"name":"allowance","type":"function","stateMutability":"view",
"inputs":[{"name":"o","type":"address"},{"name":"s","type":"address"}],
"outputs":[{"type":"uint256"}]},
]
CTF_ABI = [
{"name":"setApprovalForAll","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"operator","type":"address"},{"name":"approved","type":"bool"}],
"outputs":[]},
]
NEG_RISK_ADAPTER_ABI = [
{"name":"splitPosition","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"_conditionId","type":"bytes32"},
{"name":"_amount","type":"uint256"}], "outputs":[]},
{"name":"mergePositions","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"_conditionId","type":"bytes32"},
{"name":"_amount","type":"uint256"}], "outputs":[]},
{"name":"redeemPositions","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"_conditionId","type":"bytes32"},
{"name":"_amounts","type":"uint256[]"}], "outputs":[]},
{"name":"convertPositions","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"_marketId","type":"bytes32"},
{"name":"_indexSet","type":"uint256"},
{"name":"_amount","type":"uint256"}], "outputs":[]},
{"name":"getConditionId","type":"function","stateMutability":"view",
"inputs":[{"name":"_questionId","type":"bytes32"}],
"outputs":[{"type":"bytes32"}]},
{"name":"getPositionId","type":"function","stateMutability":"view",
"inputs":[{"name":"_questionId","type":"bytes32"},
{"name":"_outcome","type":"bool"}],
"outputs":[{"type":"uint256"}]},
]
usdc = w3.eth.contract(address=USDC_E, abi=ERC20_ABI)
ctf = w3.eth.contract(address=CTF, abi=CTF_ABI)
adapter = w3.eth.contract(address=NEG_RISK_ADAPTER, abi=NEG_RISK_ADAPTER_ABI)
# ---- 5. Approvals (idempotent) -------------------------------------------
MAX = 2**256 - 1
def ensure_approvals():
if usdc.functions.allowance(ME, NEG_RISK_ADAPTER).call() < 10**18:
tx = usdc.functions.approve(NEG_RISK_ADAPTER, MAX).build_transaction({
"from": ME, "nonce": w3.eth.get_transaction_count(ME),
"maxFeePerGas": w3.to_wei(100, "gwei"),
"maxPriorityFeePerGas": w3.to_wei(30, "gwei"),
"chainId": 137,
})
# signed = acct.sign_transaction(tx); w3.eth.send_raw_transaction(signed.raw_transaction)
print("[would broadcast] USDC.e.approve(adapter)")
# also need 1155 approval for merge/redeem/convert legs
print("[would broadcast] CTF.setApprovalForAll(adapter, true)")
ensure_approvals()
# ---- 6. Pull a candidate event from Gamma --------------------------------
events = requests.get(
"https://gamma-api.polymarket.com/events",
params={"closed":"false","limit":50,"order":"volume24hr","ascending":"false"},
timeout=15,
).json()
neg = next(e for e in events if e.get("negRisk") and e.get("markets"))
mkt = neg["markets"][0]
condition_id_hex = mkt["conditionId"] # 0x...
print(f"event: {neg['title']!r} conditionId: {condition_id_hex}")
# ---- 7. Simulate redeem on the YES leg of one binary ---------------------
# amounts MUST line up with outcome slot count (2 for binary): [yes_qty, no_qty]
SIZE = 1_000_000 # 1.0 USDC.e (6 dp); placeholder until balances are real
amounts = [SIZE, 0]
redeem_call = adapter.functions.redeemPositions(
Web3.to_bytes(hexstr=condition_id_hex),
amounts,
)
# eth_call simulation (no broadcast). Will revert if the market is unresolved
# or if you don't actually hold the tokens — both are expected for a dry run.
try:
sim = redeem_call.call({"from": ME})
print("simulated redeemPositions OK; return:", sim)
except Exception as exc:
print("simulated redeemPositions reverted (expected for dry run):", exc)
# Gas estimate for a real broadcast:
try:
gas = redeem_call.estimate_gas({"from": ME})
print("gas estimate:", gas)
except Exception as exc:
print("estimate_gas reverted (likely unresolved or no balance):", exc)
```
---
## 8. Gotchas
1. **USDC.e ≠ native USDC.** Polymarket exclusively uses **bridged USDC.e**
`0x2791…84174`. Approving native Circle USDC `0x3c499…3359` will silently
fail every order placement and adapter call. Confirm balance with
`usdc.functions.symbol().call() == "USDC"` *and* address match.
2. **Two distinct allowances.** You must `approve(USDC.e → NegRiskAdapter)`
*and* `setApprovalForAll(CTF → NegRiskAdapter, true)`. The second is
needed for `mergePositions`, `redeemPositions`, and `convertPositions`
because the adapter pulls your ERC-1155 outcome tokens before burning.
Trading additionally requires the same two approvals targeting the
**NegRiskCtfExchange** address.
3. **Gas estimates (Polygon, ~April 2026 baseline).** Approximate, varies
±30% with calldata size:
- `splitPosition` ~ 200250 k gas
- `mergePositions` ~ 200250 k gas
- `redeemPositions(N=2)` ~ 150220 k gas (single binary)
- `convertPositions` ~ 250400 k gas (depends on `indexSet` popcount)
At ~50 gwei `maxFeePerGas`, redeem costs roughly $0.005$0.02 of MATIC.
At Polygon gas spikes (>500 gwei) this can rise 10×; size the arb spread
accordingly.
4. **Resolution dependency on UMA.** Redeem only works after the
UmaCtfAdapter has called `reportPayouts` upstream and (for negRisk)
the NegRiskOperator has called `reportOutcome`. Until then
`redeemPositions` reverts with `MarketNotResolved`/payout-vector-empty.
UMA's optimistic oracle has a **2-hour liveness window** (default) per
question; disputes extend by days.
5. **No-winner / all-NO is an invalid state.** NegRisk *requires* exactly
one question per market to resolve YES. Per the adapter docs and audit:
if the oracle tries to report a second YES, `reportOutcome` reverts and
the market is stuck pending manual operator action; if all questions go
NO the system is "designed to prevent" that scenario but has no
automatic refund path. Polymarket's stated stance after past disputes
has been **no refunds for resolution disagreements**. Architect the
strategy so you can hold or sell tokens before final resolution if
ambiguity emerges.
([NegRisk docs](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/docs/NegRiskAdapter.md),
[Coindesk UMA/Polymarket dispute, Mar 2025](https://www.coindesk.com/markets/2025/03/27/polymarket-uma-communities-lock-horns-after-usd7m-ukraine-bet-resolves))
6. **Operator-only `safeTransferFrom`.** The adapter's `safeTransferFrom`
has an `onlyAdmin` modifier. Don't try to ERC-1155-transfer wrapped
positions through the adapter; transfer directly through the underlying
ConditionalTokens contract.
7. **`negRiskAugmented` events.** When `enableNegRisk` is true, new
outcomes (questions) can be appended to a marketId after creation. Your
"Σ asks" snapshot can become stale if a new candidate is added between
detection and trade — re-pull the event before lifting offers.
8. **CLOB order flag.** When placing orders against negRisk markets, you
must pass `neg_risk=True` in the order options of `py-clob-client` so
the order is signed for the NegRiskCtfExchange (`0xC5d5…80a`) instead
of the vanilla CTFExchange. Wrong exchange → orders rejected.
([NegRisk overview](https://docs.polymarket.com/developers/neg-risk/overview))
---
## Sources
- [Polymarket Contract Addresses](https://docs.polymarket.com/resources/contract-addresses)
- [Polymarket NegRisk Overview](https://docs.polymarket.com/developers/neg-risk/overview)
- [Polymarket CTF Overview](https://docs.polymarket.com/developers/CTF/overview)
- [neg-risk-ctf-adapter (repo)](https://github.com/Polymarket/neg-risk-ctf-adapter)
- [`NegRiskAdapter.sol`](https://raw.githubusercontent.com/Polymarket/neg-risk-ctf-adapter/main/src/NegRiskAdapter.sol)
- [`docs/NegRiskAdapter.md`](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/docs/NegRiskAdapter.md)
- [`CTHelpers.sol`](https://raw.githubusercontent.com/Polymarket/neg-risk-ctf-adapter/main/src/libraries/CTHelpers.sol)
- [ctf-exchange (repo)](https://github.com/Polymarket/ctf-exchange)
- [ChainSecurity NegRiskAdapter audit (Apr 2024)](https://old.chainsecurity.com/wp-content/uploads/2024/04/ChainSecurity_Polymarket_NegRiskAdapter_audit.pdf)
- [Polymarket Resolution docs](https://docs.polymarket.com/concepts/resolution)
- [Coindesk: Polymarket/UMA Ukraine bet dispute (Mar 2025)](https://www.coindesk.com/markets/2025/03/27/polymarket-uma-communities-lock-horns-after-usd7m-ukraine-bet-resolves)
- PolygonScan verifications: [NegRiskAdapter](https://polygonscan.com/address/0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296), [NegRiskCtfExchange](https://polygonscan.com/address/0xc5d563a36ae78145c45a50134d48a1215220f80a), [ConditionalTokens](https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045), [USDC.e](https://polygonscan.com/address/0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174)
+578
View File
@@ -0,0 +1,578 @@
# Polymarket CLOB Order Signing Cookbook (`py-clob-client`)
A copy-pasteable reference for signing and submitting Polymarket CLOB orders from
Python. Verified against `py-clob-client` **v0.34.6** (released 2026-02-19).
All citations point at `Polymarket/py-clob-client@main` on GitHub. Update the pin
when bumping.
---
## 1. Install
Pin the exact version your executor was tested against. As of April 2026 the
latest published release is **0.34.6**.
```bash
pip install py-clob-client==0.34.6
```
Source: <https://pypi.org/project/py-clob-client/0.34.6/> /
[`setup.py` L7-L25](https://github.com/Polymarket/py-clob-client/blob/main/setup.py#L7-L25)
Transitive deps it pulls in (from `setup.py`):
- `eth-account>=0.13.0`
- `eth-utils>=4.1.1`
- `poly_eip712_structs>=0.0.1`
- `py-order-utils>=0.3.2`
- `py-builder-signing-sdk>=0.0.2`
- `httpx[http2]>=0.27.0`
- `python-dotenv`
Requires Python **3.9.10+**.
The HTTP client under the hood is `httpx` (sync). All `client.*` methods are
**blocking**. See the `asyncio` pattern in section 7.
---
## 2. One-Time Setup: Derive L2 API Credentials
L2 (HMAC) creds — `api_key`, `api_secret`, `api_passphrase` — are deterministic
for a given `(wallet, nonce)`. You generate them once and store them. The
`create_or_derive_api_creds()` helper tries `POST /auth/api-key` first, and falls
back to `GET /auth/derive-api-key` if the key already exists.
Reference:
[`py_clob_client/client.py` L211-L260](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L211-L260)
```python
# scripts/bootstrap_clob_creds.py
"""
Run ONCE per wallet to mint L2 API credentials, then store the three
strings (api_key, api_secret, api_passphrase) in your secret manager.
"""
import os
from py_clob_client.client import ClobClient
from py_clob_client.constants import POLYGON
HOST = "https://clob.polymarket.com"
PRIVATE_KEY = os.environ["POLY_PK"] # 0x-prefixed hex
CHAIN_ID = POLYGON # 137 (mainnet) or AMOY=80002
# L1 client = host + chain + key. No creds needed yet.
client = ClobClient(HOST, key=PRIVATE_KEY, chain_id=CHAIN_ID)
# Idempotent: creates if missing, derives if existing. Returns ApiCreds.
creds = client.create_or_derive_api_creds()
print("CLOB_API_KEY =", creds.api_key)
print("CLOB_SECRET =", creds.api_secret)
print("CLOB_PASS_PHRASE =", creds.api_passphrase)
```
`ApiCreds` is a dataclass with three string fields:
[`clob_types.py` L19-L23](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L19-L23).
> Polymarket prints a giant warning that creds **cannot be recovered** if lost
> — store them in your secrets backend immediately.
> See [`constants.py` L7-L10](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/constants.py#L7-L10).
---
## 3. Client Init
```python
import os
from py_clob_client.client import ClobClient
from py_clob_client.clob_types import ApiCreds
from py_clob_client.constants import POLYGON
client = ClobClient(
host="https://clob.polymarket.com",
key=os.environ["POLY_PK"], # private key of the *signer* EOA
chain_id=POLYGON, # 137
creds=ApiCreds(
api_key=os.environ["CLOB_API_KEY"],
api_secret=os.environ["CLOB_SECRET"],
api_passphrase=os.environ["CLOB_PASS_PHRASE"],
),
signature_type=2, # see table below
funder="0xYourPolymarketProxyAddress", # USDC-holding address
)
```
Constructor signature:
[`client.py` L116-L165](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L116-L165).
### `signature_type`
The integer is forwarded to `OrderBuilder.__init__` and stamped into the EIP-712
order payload as `signatureType`
([`order_builder/builder.py` L40-L49](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/order_builder/builder.py#L40-L49),
[L143](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/order_builder/builder.py#L143)).
| `signature_type` | Wallet Model | When to use |
| ---------------- | ------------------------------------- | ------------------------------------------------------------------------------------------ |
| `0` (default, `EOA`) | Plain EOA / MetaMask / hardware | Signer EOA *is* the funder. USDC and CTF tokens sit on the same address that signs. |
| `1` (`POLY_PROXY`) | Polymarket proxy (Magic / email) | Signer EOA is the session key; funds live in a Polymarket-deployed proxy contract. |
| `2` (`POLY_GNOSIS_SAFE`) | Gnosis Safe / browser proxy | Signer EOA is an owner; funds live in a Safe / proxy contract. |
Default if omitted is `EOA` (0). See `EOA` constant in
`py_order_utils.model` re-exported via `builder.py` L4.
### `funder`
The address that **holds USDC and conditional tokens**. This goes into the
`maker` field of the signed order
([`builder.py` L132-L144](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/order_builder/builder.py#L132-L144)),
while `signer` is set to the address derived from your private key.
- If `funder` is omitted, it defaults to `signer.address()`.
- For arbitrage from a Polymarket UI account, `funder` = your visible Polymarket
proxy address (look it up on polygonscan or in the UI), and `key` = the
session/EOA key Polymarket gave you.
- For a pure EOA setup, leave `funder=None` (or pass the same address as the
signer) and use `signature_type=0`.
### Read-only mode
Drop `creds`, `key`, `signature_type`, `funder` for L0 (public endpoints only):
```python
client = ClobClient("https://clob.polymarket.com")
client.get_order_book(token_id)
```
---
## 4. Place an Order
### 4a. Limit order — GTC (resting)
Source pattern:
[`examples/order.py` L1-L36](https://github.com/Polymarket/py-clob-client/blob/main/examples/order.py).
```python
from py_clob_client.clob_types import OrderArgs, OrderType
from py_clob_client.order_builder.constants import BUY, SELL
order_args = OrderArgs(
token_id="71321045679252212594626385532706912750332728571942532289631379312455583992563",
price=0.42, # USD per share, between 0.0 and 1.0
size=100.0, # shares
side=BUY, # or SELL
)
signed = client.create_order(order_args) # builds EIP-712 + signs
resp = client.post_order(signed, OrderType.GTC)
# resp == {"success": True, "orderID": "0x...", "status": "matched"|"live"|...}
```
`OrderArgs` fields (from
[`clob_types.py` L42-L83](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L42-L83)):
`token_id`, `price`, `size`, `side`, `fee_rate_bps=0`, `nonce=0`,
`expiration=0`, `taker=ZERO_ADDRESS`.
`create_order` automatically:
- fetches and caches the market's `tick_size` and `neg_risk` flag
([`client.py` L402-L448, L492-L535](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L492-L535)),
- validates your price against the tick,
- picks the correct exchange contract for `neg_risk` markets,
- signs an EIP-712 order with `maker=funder`, `signer=EOA`, `signatureType=...`.
### 4b. Limit order — GTD (good-till-date)
Source: [`examples/GTD_order.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/GTD_order.py).
```python
order_args = OrderArgs(
token_id="...",
price=0.50,
size=100.0,
side=BUY,
expiration="1000000000000", # unix seconds; must be > now+60s
)
signed = client.create_order(order_args)
resp = client.post_order(signed, OrderType.GTD)
```
### 4c. FOK (Fill-Or-Kill)
FOK requires the entire size to fill immediately at the limit price or better,
otherwise the whole order is cancelled. Polymarket uses FOK for *market* buys
priced in dollars (`MarketOrderArgs.amount`).
Source: [`examples/market_buy_order.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/market_buy_order.py).
```python
from py_clob_client.clob_types import MarketOrderArgs, OrderType
mo = MarketOrderArgs(
token_id="...",
amount=100.0, # BUY: USDC to spend. SELL: shares to sell.
side=BUY,
)
signed = client.create_market_order(mo)
resp = client.post_order(signed, orderType=OrderType.FOK)
```
`MarketOrderArgs` defaults `order_type=OrderType.FOK`
([`clob_types.py` L86-L122](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L86-L122)).
### 4d. FAK / IOC (Fill-And-Kill, a.k.a. Immediate-Or-Cancel)
`OrderType.FAK` is Polymarket's IOC variant — fills as much as possible
immediately, cancels the unfilled remainder. Use this for arbitrage legs where
partial fills are acceptable.
```python
from py_clob_client.clob_types import OrderArgs, OrderType
from py_clob_client.order_builder.constants import BUY
order_args = OrderArgs(token_id="...", price=0.42, size=100.0, side=BUY)
signed = client.create_order(order_args)
resp = client.post_order(signed, OrderType.FAK) # IOC behavior
```
Enum values:
[`clob_types.py` L11-L16`](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L11-L16):
```python
class OrderType(enumerate):
GTC = "GTC" # resting limit
FOK = "FOK" # all-or-nothing immediate
GTD = "GTD" # resting limit with expiry
FAK = "FAK" # IOC: fill what you can, cancel rest
```
> **TL;DR for an arbitrage executor**: use `FAK` for legs where you want IOC
> semantics on a limit order, and `FOK` for $-denominated market-sweep buys
> where you only want the trade if the full notional clears.
`post_only=True` is only legal with `GTC` / `GTD`
([`client.py` L623-L628](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L623-L628)).
---
## 5. Cancel Orders
Source: [`examples/cancel_order.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/cancel_order.py),
[`examples/cancel_orders.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/cancel_orders.py).
```python
# single
client.cancel(order_id="0xabc...")
# batch
client.cancel_orders(["0xabc...", "0xdef..."])
# all open orders for this API key
client.cancel_all()
# all orders on a market or token
client.cancel_market_orders(market="0x...condition_id...", asset_id="")
client.cancel_market_orders(market="", asset_id="<token_id>")
```
Implementations:
[`client.py` L663-L748](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L663-L748).
---
## 6. Get Positions / Open Orders / Fills
py-clob-client does **not** ship a `get_positions()` method — Polymarket exposes
"positions" via the Data-API (separate service). Within `py-clob-client` you use
**balance/allowance** for current token holdings, **`get_orders`** for open
orders, and **`get_trades`** for fills.
### Balance / position per token
Source: [`examples/get_balance_allowance.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/get_balance_allowance.py).
```python
from py_clob_client.clob_types import BalanceAllowanceParams, AssetType
# USDC balance + exchange allowance
usdc = client.get_balance_allowance(
BalanceAllowanceParams(asset_type=AssetType.COLLATERAL)
)
# Conditional-token (outcome share) balance for one token_id
shares = client.get_balance_allowance(
BalanceAllowanceParams(
asset_type=AssetType.CONDITIONAL,
token_id="71321045679252212594626385532706912750332728571942532289631379312455583992563",
)
)
```
Note: `BalanceAllowanceParams.signature_type` defaults to `-1` and is auto-filled
from the client.
### Open orders
```python
from py_clob_client.clob_types import OpenOrderParams
orders = client.get_orders(OpenOrderParams()) # all
orders = client.get_orders(OpenOrderParams(market="0x...condition")) # one market
orders = client.get_orders(OpenOrderParams(asset_id="<token_id>")) # one token
```
`get_orders` paginates internally with `next_cursor`
([`client.py` L750-L769](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L750-L769)).
### Fills (trades)
Source: [`examples/get_trades.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/get_trades.py).
```python
from py_clob_client.clob_types import TradeParams
trades = client.get_trades(
TradeParams(
maker_address=client.get_address(),
market="0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1",
)
)
```
---
## 7. Multi-Leg / Parallel Order Submission
`py-clob-client` is **synchronous** (it uses `httpx` in blocking mode). For
arbitrage you have two good options:
### Option A — Server-side batch (preferred when atomicity matters less)
`post_orders` ships N orders in one HTTP round-trip. Lower latency than N
parallel calls, but the server processes them serially.
Source: [`examples/orders.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/orders.py).
```python
from py_clob_client.clob_types import OrderArgs, PostOrdersArgs, OrderType
from py_clob_client.order_builder.constants import BUY, SELL
resp = client.post_orders([
PostOrdersArgs(
order=client.create_order(OrderArgs(
token_id="...YES_TOKEN_ID...",
price=0.50, size=100, side=BUY)),
orderType=OrderType.FAK,
postOnly=False,
),
PostOrdersArgs(
order=client.create_order(OrderArgs(
token_id="...NO_TOKEN_ID...",
price=0.51, size=100, side=BUY)),
orderType=OrderType.FAK,
postOnly=False,
),
])
```
Implementation: [`client.py` L592-L621](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L592-L621).
### Option B — `asyncio.gather` over a thread pool (true parallel HTTP)
When you want each leg to be a separate request fired concurrently (useful for
hitting the matching engine at the same time across markets), wrap the sync
client with `loop.run_in_executor`:
```python
import asyncio
from concurrent.futures import ThreadPoolExecutor
from py_clob_client.clob_types import OrderArgs, OrderType
from py_clob_client.order_builder.constants import BUY
# One executor for the whole process is fine. Size = max parallel legs.
_EXECUTOR = ThreadPoolExecutor(max_workers=16)
async def submit_leg(client, order_args: OrderArgs, order_type: OrderType):
loop = asyncio.get_running_loop()
# create_order signs (CPU + 1 cached HTTP call for tick/neg_risk),
# post_order does the actual order POST.
signed = await loop.run_in_executor(_EXECUTOR, client.create_order, order_args)
return await loop.run_in_executor(
_EXECUTOR, client.post_order, signed, order_type
)
async def execute_arb(client, legs: list[tuple[OrderArgs, OrderType]]):
return await asyncio.gather(
*(submit_leg(client, args, ot) for args, ot in legs),
return_exceptions=True, # don't let one failure cancel the others
)
# Usage
legs = [
(OrderArgs(token_id=YES, price=0.50, size=100, side=BUY), OrderType.FAK),
(OrderArgs(token_id=NO, price=0.51, size=100, side=BUY), OrderType.FAK),
]
results = asyncio.run(execute_arb(client, legs))
```
Notes:
- The `ClobClient` instance is safe to share across threads for read paths and
for `post_order`. Each call constructs its own `httpx` request.
- Pre-warm tick/`neg_risk` caches by calling `client.get_tick_size(token_id)`
and `client.get_neg_risk(token_id)` once at startup so the hot path skips two
HTTP round trips per `create_order`. Caches live on the client
([`client.py` L156-L160, L402-L448](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L402-L448)).
- The tick-size cache TTL is configurable via the `tick_size_ttl` ctor arg
(default 300s).
---
## 8. NegRisk Markets
NegRisk ("negative-risk") markets are Polymarket's multi-outcome markets where
the YES tokens of all outcomes sum to ~$1. They use a **different exchange
contract** than vanilla binary markets, but the SDK handles the routing for you.
### What you do NOT need to do
`OrderArgs` is **identical** for negRisk and vanilla tokens — you still pass
`token_id`, `price`, `size`, `side`. There is no `neg_risk` field on
`OrderArgs`.
### What the SDK does behind the scenes
When you call `client.create_order(...)`, it:
1. Calls `GET /neg-risk?token_id=...` to learn whether the token belongs to a
negRisk market (cached forever per `token_id`
[`client.py` L441-L448](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L441-L448)).
2. Calls `get_contract_config(chain_id, neg_risk=True/False)` to pick the right
exchange address ([`builder.py` L146-L154](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/order_builder/builder.py#L146-L154)).
3. Signs the EIP-712 payload against that exchange's domain separator.
### When you DO need to override
If you already know the market is negRisk and want to skip the lookup, pass
`PartialCreateOrderOptions`:
```python
from py_clob_client.clob_types import PartialCreateOrderOptions
signed = client.create_order(
order_args,
options=PartialCreateOrderOptions(neg_risk=True, tick_size="0.01"),
)
```
(definition:
[`clob_types.py` L165-L172](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L165-L172).)
### Allowances (one-time, per signer)
For EOAs (`signature_type=0`) you must approve **both** the vanilla CTF Exchange
**and** the NegRisk CTF Exchange + NegRisk Adapter on Polygon mainnet.
Magic/proxy wallets (`signature_type=1` or `2`) have allowances set
automatically. From the README:
| Token | Approve for |
| --------------------------------------- | --------------------------------------------- |
| USDC (`0x2791Bca1...A84174`) | `0x4bFb41d5...8B8982E` (CTF Exchange) |
| Conditional Tokens (`0x4D97DCd9...476045`) | `0xC5d563A3...220f80a` (NegRisk Exchange) |
| | `0xd91E80cF...0DA35296` (NegRisk Adapter) |
Reference allowance script (linked in the README):
<https://gist.github.com/poly-rodr/44313920481de58d5a3f6d1f8226bd5e>
---
## 9. Error Handling
### Exception hierarchy
All client exceptions live in
[`py_clob_client/exceptions.py`](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/exceptions.py):
```python
class PolyException(Exception):
msg: str
class PolyApiException(PolyException):
status_code: int | None # HTTP status from the failed response
error_msg: dict | str # parsed JSON or raw text body
```
`PolyApiException` is raised inside `http_helpers/helpers.py` for any non-2xx
HTTP response. `httpx` exceptions (`httpx.RequestError`,
`httpx.TimeoutException`, `httpx.HTTPError`) can leak through on network/DNS
failures.
### Recommended catch ladder
```python
import httpx
from py_clob_client.exceptions import PolyApiException, PolyException
try:
resp = client.post_order(signed, OrderType.FAK)
except PolyApiException as e:
# API-layer failure: invalid price, insufficient balance, market closed, 4xx/5xx
if e.status_code in (429, 502, 503, 504):
# rate-limited or transient — retry with backoff
...
elif e.status_code in (400, 422):
# client error — DO NOT retry; surface to operator
...
else:
...
except (httpx.TimeoutException, httpx.RequestError) as e:
# Network-layer failure — safe to retry idempotently if you used a fresh nonce
...
except PolyException as e:
# Local SDK validation (e.g., invalid tick size, bad side)
...
```
### Retry guidance for an arbitrage executor
- **Idempotency**: Polymarket assigns the order ID on the server side from the
EIP-712 hash, so re-posting the *exact same signed payload* is naturally
idempotent for `GTC`/`GTD`. For `FAK`/`FOK`, the hash includes the salt so a
fresh `create_order` call generates a *new* order — only retry if you
confirmed via `get_orders` / `get_trades` that the original did not fill.
- **Cap retries at 1-2** for any order-placement call; latency-sensitive
arbitrage prefers fast failure over duplicated risk.
- **Pre-flight checks** before any loop: `client.get_balance_allowance(...)`
for both legs, `client.get_tick_size(token_id)` to warm the cache.
- **Heartbeat-based dead-man switch**: `client.post_heartbeat(heartbeat_id)`
cancels all your orders if no heartbeat arrives within 10s — useful as a
safety net while the executor is running
([`client.py` L713-L727](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L713-L727)).
### Common API-side rejection reasons
- `not enough balance / allowance` — top up USDC or re-run the allowance script.
- `min size not met``OrderBookSummary.min_order_size`.
- `tick size invalid` — your `price` doesn't fit the market's tick. Use
`client.get_tick_size(token_id)` and round.
- `market not active` — market is paused, resolved, or closed.
- `order expired` — for `GTD`, `expiration` must be > `now + 60s`.
---
## 10. Constants Cheat Sheet
```python
from py_clob_client.constants import POLYGON, AMOY # 137, 80002
from py_clob_client.order_builder.constants import BUY, SELL # "BUY", "SELL"
from py_clob_client.clob_types import OrderType, AssetType
# OrderType.GTC | FOK | GTD | FAK
# AssetType.COLLATERAL | CONDITIONAL
```
`POLYGON = 137`, `AMOY = 80002` (testnet) — see
[`constants.py`](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/constants.py).
Default CLOB host: `https://clob.polymarket.com`.
+484
View File
@@ -0,0 +1,484 @@
/*
* Live Polymarket arbitrage scanner portfolio demo.
*
* What this does, in plain English:
* - Loads a list of 68 currently-active categorical Polymarket events
* from a static JSON file that a GitHub Action refreshes every hour.
* - Opens a WebSocket to Polymarket's public CLOB and subscribes to every
* outcome token of those events.
* - Maintains order book state per outcome and recomputes "basket cost"
* the total cost of buying one share of every answer on every update.
* - If that basket cost drops below $1.00, it's a risk-free arbitrage and
* the page flashes green.
*
* This is a browser-only port of the Python engine in the repo. No backend,
* no keys, no orders submitted.
*/
const EVENTS_URL = "./data/events.json";
const WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market";
const MAX_EVENTS_TO_WATCH = 6;
const PING_INTERVAL_MS = 10_000;
const NEAR_ARB_THRESHOLD = 0.01; // $0.01 above $1 counts as "near miss"
// --- DOM shortcuts ---------------------------------------------------------
const $ = (sel) => document.querySelector(sel);
const el = {
connDot: $("#conn-dot"),
connLabel: $("#conn-label"),
aLine: $("#a-line"),
aSub: $("#a-sub"),
watchCount: $("#watch-count"),
eventList: $("#event-list"),
eventTitle: $("#event-title"),
eventSub: $("#event-subtitle"),
whyBtn: $("#why-this-event"),
basketCost: $("#basket-cost"),
basketCostSub:$("#basket-cost-sub"),
basketPnl: $("#basket-pnl"),
basketPnlSub: $("#basket-pnl-sub"),
basketStatus: $("#basket-status"),
basketFill: $("#basket-fill"),
scaleButtons: $("#scale-buttons"),
scaleOut: $("#scale-out"),
outcomeList: $("#outcome-list"),
statEvents: $("#stat-events"),
statTokens: $("#stat-tokens"),
statBooks: $("#stat-books"),
statMsgs: $("#stat-msgs"),
statUptime: $("#stat-uptime"),
modal: $("#modal"),
modalContent: $("#modal-content"),
};
// --- state ----------------------------------------------------------------
const state = {
events: {},
books: {},
tokenToEvent: {},
selectedEventId: null,
msgCount: 0,
startTime: Date.now(),
ws: null,
pingTimer: null,
scale: 1,
};
class Ladder {
constructor() { this.m = {}; this.sortedKeys = null; }
set(price, size) { if (size <= 0) this.del(price); else { this.m[price] = size; this.sortedKeys = null; } }
del(price) { delete this.m[price]; this.sortedKeys = null; }
clear() { this.m = {}; this.sortedKeys = null; }
size() { return Object.keys(this.m).length; }
keys() { if (this.sortedKeys === null) this.sortedKeys = Object.keys(this.m).map(parseFloat).sort((a,b)=>a-b); return this.sortedKeys; }
best(ascending) {
const ks = this.keys();
if (!ks.length) return null;
const p = ascending ? ks[0] : ks[ks.length - 1];
return { price: p, size: this.m[p] };
}
}
// --- bootstrap ------------------------------------------------------------
bootstrap().catch((err) => {
console.error("bootstrap failed", err);
setConn("bad", "unable to load events — " + err.message);
el.aLine.innerHTML = `<span class="verdict no">Couldn't load.</span>`;
el.aSub.textContent = "Refresh the page in a minute — the events list refreshes hourly.";
});
async function bootstrap() {
setConn("pend", "loading active events…");
const events = await fetchActiveNegRiskEvents();
if (!events.length) {
setConn("bad", "no active events found");
return;
}
for (const e of events) {
state.events[e.id] = e;
for (const o of e.outcomes) state.tokenToEvent[o.token_id] = e.id;
}
state.selectedEventId = events[0].id;
el.watchCount.textContent = events.length;
el.statEvents.textContent = events.length;
el.statTokens.textContent = Object.keys(state.tokenToEvent).length;
renderEventList();
renderSelectedEvent();
wireInteractions();
startUptimeTicker();
connectWS();
}
// --- REST (same-origin events.json — GitHub Action refreshes hourly) ------
async function fetchActiveNegRiskEvents() {
const resp = await fetch(EVENTS_URL + "?t=" + Date.now());
if (!resp.ok) throw new Error("events.json " + resp.status);
const payload = await resp.json();
const raw = Array.isArray(payload?.events) ? payload.events : [];
const kept = [];
for (const ev of raw) {
if (!Array.isArray(ev.outcomes) || ev.outcomes.length < 2) continue;
kept.push({
id: String(ev.id),
title: String(ev.title || "Event"),
slug: ev.slug,
outcomes: ev.outcomes.map(o => ({ token_id: String(o.token_id), name: String(o.name) })),
sum: null,
lastUpdate: 0,
});
if (kept.length >= MAX_EVENTS_TO_WATCH) break;
}
return kept;
}
// --- WebSocket ------------------------------------------------------------
function connectWS() {
const tokenIds = Object.keys(state.tokenToEvent);
if (!tokenIds.length) { setConn("bad", "no tokens"); return; }
setConn("pend", "opening connection to Polymarket…");
const ws = new WebSocket(WS_URL);
state.ws = ws;
ws.addEventListener("open", () => {
setConn("on", `live · reading order books for ${Object.keys(state.events).length} events`);
ws.send(JSON.stringify({ type: "market", assets_ids: tokenIds, custom_feature_enabled: true }));
if (state.pingTimer) clearInterval(state.pingTimer);
state.pingTimer = setInterval(() => { try { ws.send("PING"); } catch {} }, PING_INTERVAL_MS);
});
ws.addEventListener("message", (ev) => {
const raw = ev.data;
if (typeof raw === "string") {
const t = raw.trim();
if (t === "PONG" || t === "PING") return;
try { handlePayload(JSON.parse(t)); } catch {}
}
});
ws.addEventListener("close", () => {
setConn("bad", "connection dropped — reconnecting…");
if (state.pingTimer) clearInterval(state.pingTimer);
setTimeout(connectWS, 3000);
});
ws.addEventListener("error", () => setConn("bad", "connection error"));
}
function handlePayload(payload) {
if (Array.isArray(payload)) for (const m of payload) dispatch(m);
else if (payload && typeof payload === "object") dispatch(payload);
}
function dispatch(msg) {
state.msgCount++;
el.statMsgs.textContent = state.msgCount.toLocaleString();
const t = msg.event_type;
if (t === "book") applyBookSnapshot(msg);
else if (t === "price_change") applyPriceChange(msg);
}
function applyBookSnapshot(msg) {
const assetId = msg.asset_id;
if (!assetId || !state.tokenToEvent[assetId]) return;
const book = getBook(assetId);
book.bids.clear(); book.asks.clear();
for (const lvl of (msg.bids || [])) {
const p = parseFloat(lvl.price), s = parseFloat(lvl.size);
if (p > 0 && s > 0) book.bids.set(p, s);
}
for (const lvl of (msg.asks || [])) {
const p = parseFloat(lvl.price), s = parseFloat(lvl.size);
if (p > 0 && s > 0) book.asks.set(p, s);
}
onBookUpdate(assetId);
}
function applyPriceChange(msg) {
if (!Array.isArray(msg.price_changes)) return;
const touched = new Set();
for (const c of msg.price_changes) {
const assetId = c.asset_id;
if (!assetId || !state.tokenToEvent[assetId]) continue;
const p = parseFloat(c.price), s = parseFloat(c.size);
if (!(p > 0) || isNaN(s)) continue;
const book = getBook(assetId);
const side = (c.side || "").toUpperCase();
if (side === "BUY") book.bids.set(p, s);
else if (side === "SELL") book.asks.set(p, s);
else continue;
touched.add(assetId);
}
for (const id of touched) onBookUpdate(id);
}
function getBook(tokenId) {
if (!state.books[tokenId]) {
state.books[tokenId] = { bids: new Ladder(), asks: new Ladder() };
el.statBooks.textContent = Object.keys(state.books).length.toLocaleString();
}
return state.books[tokenId];
}
// --- engine ---------------------------------------------------------------
function onBookUpdate(tokenId) {
const eventId = state.tokenToEvent[tokenId];
if (!eventId) return;
evaluateEvent(eventId);
renderEventList();
if (eventId === state.selectedEventId) renderSelectedEvent();
renderHeroAnswer();
}
function evaluateEvent(eventId) {
const ev = state.events[eventId];
if (!ev) return;
let sum = 0, missing = 0;
for (const o of ev.outcomes) {
const b = state.books[o.token_id];
const best = b && b.asks.best(true);
if (!best) { missing += 1; continue; }
sum += best.price;
}
ev.sum = missing > 0 ? null : sum;
ev.lastUpdate = Date.now();
}
// --- rendering ------------------------------------------------------------
function setConn(cls, text) {
el.connDot.className = "dot " + cls;
el.connLabel.textContent = text;
}
function renderHeroAnswer() {
// Find the cheapest basket across all events
let bestEv = null, bestSum = Infinity;
for (const id of Object.keys(state.events)) {
const ev = state.events[id];
if (ev.sum === null || ev.sum === undefined) continue;
if (ev.sum < bestSum) { bestSum = ev.sum; bestEv = ev; }
}
if (!bestEv) {
el.aLine.innerHTML = `<span class="spinner"></span><span class="muted">checking live prices…</span>`;
return;
}
const cls = classForSum(bestSum);
const gap = bestSum - 1;
if (cls === "arb") {
const bps = Math.round(-gap * 10_000);
el.aLine.innerHTML = `<span class="verdict yes">Yes!</span> <span class="detail">The cheapest bundle is</span> <span class="cost-chip yes">$${bestSum.toFixed(4)}</span><span class="detail">— free ${Math.abs(bps)} bps (${(-gap*100).toFixed(2)}¢) per $1 bet</span>`;
el.aSub.innerHTML = `On <strong>${escapeHtml(bestEv.title)}</strong>. Click it on the left to see the details.`;
} else if (cls === "near") {
el.aLine.innerHTML = `<span class="verdict near">Almost.</span> <span class="detail">The cheapest bundle is</span> <span class="cost-chip near">$${bestSum.toFixed(4)}</span><span class="detail">— you'd lose ${(gap*100).toFixed(2)}¢ per $1 bet</span>`;
el.aSub.innerHTML = `On <strong>${escapeHtml(bestEv.title)}</strong>. Watch it — if another trader sells off this might flip into arbitrage.`;
} else {
el.aLine.innerHTML = `<span class="verdict no">Not right now.</span> <span class="detail">The cheapest bundle is</span> <span class="cost-chip no">$${bestSum.toFixed(4)}</span><span class="detail">— you'd lose ${(gap*100).toFixed(2)}¢ per $1 bet</span>`;
el.aSub.innerHTML = `Watching <strong>${Object.keys(state.events).length} events</strong>. This is the normal state — arbitrage windows are rare.`;
}
}
function renderEventList() {
el.eventList.innerHTML = "";
for (const id of Object.keys(state.events)) {
const ev = state.events[id];
const cls = classForSum(ev.sum);
const costText = ev.sum === null ? "waiting" : "$" + ev.sum.toFixed(3);
const costCls = ev.sum === null ? "" : cls === "arb" ? "yes" : cls === "near" ? "near" : "no";
const card = document.createElement("div");
card.className = "event-card" + (id === state.selectedEventId ? " active" : "");
card.innerHTML = `
<div class="ec-title">${escapeHtml(ev.title)}</div>
<div class="ec-meta">
<span class="ec-count">${ev.outcomes.length} possible answers</span>
<span class="ec-cost ${costCls}">${costText}</span>
</div>
`;
card.addEventListener("click", () => {
state.selectedEventId = id;
renderEventList();
renderSelectedEvent();
});
el.eventList.appendChild(card);
}
}
function renderSelectedEvent() {
const ev = state.events[state.selectedEventId];
if (!ev) return;
el.eventTitle.textContent = ev.title;
el.eventSub.textContent = `${ev.outcomes.length} possible answers · exactly one will win and pay $1`;
let sum = 0, complete = true;
const rows = [];
for (const o of ev.outcomes) {
const b = state.books[o.token_id];
const best = b && b.asks.best(true);
if (!best) { complete = false; rows.push({ ...o, price: null, size: null }); }
else { sum += best.price; rows.push({ ...o, price: best.price, size: best.size }); }
}
rows.sort((a, b) => (b.price ?? -1) - (a.price ?? -1));
renderCalcCard(complete ? sum : null);
renderOutcomeList(rows, complete ? sum : null);
}
function renderCalcCard(sum) {
if (sum === null) {
el.basketCost.textContent = "—";
el.basketCost.className = "cell-val";
el.basketCostSub.textContent = "waiting for every answer's order book…";
el.basketPnl.textContent = "—";
el.basketPnl.className = "cell-val";
el.basketPnlSub.textContent = "per one-set bet";
el.basketStatus.textContent = "loading";
el.basketStatus.className = "status fair";
el.basketFill.style.width = "0%";
el.basketFill.className = "fill";
el.scaleOut.innerHTML = "";
return;
}
const cls = classForSum(sum);
const delta = sum - 1;
// scale
const q = state.scale;
const totalCost = sum * q;
const totalGet = 1 * q;
const pnl = totalGet - totalCost;
el.basketCost.textContent = "$" + sum.toFixed(4);
el.basketCost.className = "cell-val";
el.basketCostSub.textContent = q === 1 ? "for one full set" : `× ${q} sets = $${totalCost.toFixed(2)} total`;
el.basketPnl.textContent = (pnl >= 0 ? "+" : "") + "$" + pnl.toFixed(q >= 100 ? 2 : 4);
el.basketPnl.className = "cell-val " + (cls === "arb" ? "pos" : cls === "near" ? "near" : "neg");
el.basketPnlSub.textContent = q === 1
? (cls === "arb" ? "guaranteed — buy + redeem" : cls === "near" ? "you'd lose a tiny bit" : "you'd lose this no matter what")
: `on a ${q}-set bet`;
el.basketStatus.textContent = cls === "arb" ? "ARBITRAGE" : cls === "near" ? "NEAR MISS" : "NO ARB";
el.basketStatus.className = "status " + cls;
// interpretation line
if (cls === "arb") {
el.scaleOut.innerHTML = `
Pay <strong>$${totalCost.toFixed(2)}</strong>,
receive <strong>$${totalGet.toFixed(2)}</strong> when the event resolves,
pocket <span class="gain">+$${pnl.toFixed(2)}</span> risk-free.
Every leg fills and the winning outcome pays $1.
`;
} else {
const perSet = delta;
el.scaleOut.innerHTML = `
Pay <strong>$${totalCost.toFixed(2)}</strong>,
receive <strong>$${totalGet.toFixed(2)}</strong> when the event resolves,
net <span class="loss">$${Math.abs(pnl).toFixed(2)}</span>.
That's ${(perSet*100).toFixed(2)}¢ too expensive per set no arbitrage.
`;
}
const pct = Math.max(0, Math.min(100, ((sum - 0.80) / 0.40) * 100));
el.basketFill.style.width = pct.toFixed(1) + "%";
el.basketFill.className = "fill" + (cls === "arb" ? " arb" : cls === "near" ? " near" : "");
}
function renderOutcomeList(rows, totalSum) {
el.outcomeList.innerHTML = "";
for (const r of rows) {
const hasPrice = r.price !== null;
const pct = hasPrice ? (r.price * 100) : 0;
const highlighted = hasPrice && totalSum !== null && classForSum(totalSum) === "arb";
const row = document.createElement("div");
row.className = "outcome-row" + (!hasPrice ? " empty" : "") + (highlighted ? " highlighted" : "");
row.innerHTML = `
<div class="left">
<div class="outcome-name">${escapeHtml(r.name)}</div>
<div class="prob-bar"><div class="prob-fill" style="width: ${Math.min(100, pct).toFixed(1)}%"></div></div>
</div>
<div class="right">
<div class="pct">${hasPrice ? pct.toFixed(1) + "%" : "waiting"}</div>
<div class="price-size">${hasPrice ? `$${r.price.toFixed(4)} · ${formatSize(r.size)} available` : "no offers yet"}</div>
</div>
`;
el.outcomeList.appendChild(row);
}
}
function classForSum(sum) {
if (sum === null || sum === undefined) return "fair";
if (sum < 1.0) return "arb";
if (sum <= 1.0 + NEAR_ARB_THRESHOLD) return "near";
return "fair";
}
// --- interactions ---------------------------------------------------------
function wireInteractions() {
// scale buttons
el.scaleButtons.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-qty]");
if (!btn) return;
state.scale = parseInt(btn.dataset.qty, 10) || 1;
for (const b of el.scaleButtons.querySelectorAll("button")) b.classList.toggle("active", b === btn);
renderSelectedEvent();
});
// "what is this event" modal
el.whyBtn.addEventListener("click", () => {
const ev = state.events[state.selectedEventId];
if (!ev) return;
const pmUrl = ev.slug ? `https://polymarket.com/event/${encodeURIComponent(ev.slug)}` : "https://polymarket.com";
el.modalContent.innerHTML = `
<h3>${escapeHtml(ev.title)}</h3>
<p>
This is a real, currently-open question on Polymarket. There are
<strong>${ev.outcomes.length} possible answers</strong>, and when the real
event resolves, one will be declared the winner. Shares of the winning
answer pay $1. Shares of every losing answer pay $0.
</p>
<p>
The numbers on this page come straight from Polymarket's live order book
same feed their website uses. See the original market here:
</p>
<p><a href="${pmUrl}" target="_blank" rel="noopener">View on polymarket.com </a></p>
`;
el.modal.hidden = false;
});
// modal close
el.modal.addEventListener("click", (e) => {
if (e.target.dataset?.close !== undefined) el.modal.hidden = true;
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") el.modal.hidden = true;
});
}
function startUptimeTicker() {
setInterval(() => {
const secs = Math.floor((Date.now() - state.startTime) / 1000);
const mm = String(Math.floor(secs / 60)).padStart(2, "0");
const ss = String(secs % 60).padStart(2, "0");
el.statUptime.textContent = `${mm}:${ss}`;
}, 1000);
}
// --- utils ----------------------------------------------------------------
function formatSize(s) {
if (s >= 1_000_000) return (s / 1_000_000).toFixed(2) + "M";
if (s >= 10_000) return (s / 1_000).toFixed(1) + "k";
if (s >= 1_000) return (s / 1_000).toFixed(2) + "k";
return s.toFixed(0);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
+333
View File
@@ -0,0 +1,333 @@
/* Lab page — extends style.css with strategy-lab-specific layout */
.lab-shell { min-height: 100vh; background: var(--bg); }
/* Tabs */
.tabs {
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 10;
}
.tabs-inner {
max-width: 1120px; margin: 0 auto; padding: 0 1.5rem;
display: flex; gap: 0.2rem;
}
.tab {
background: transparent; border: 0;
color: var(--text-3); font-size: 0.95rem; font-weight: 500;
padding: 1rem 1.4rem; cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.1s ease, border-color 0.1s ease;
display: flex; align-items: center; gap: 0.5rem;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-count {
font-family: var(--mono); font-size: 0.78rem;
padding: 1px 7px; border-radius: 999px;
background: var(--surface-2); color: var(--text-3);
}
.tab.active .tab-count { background: var(--accent); color: white; }
.panel { display: none; }
.panel.active { display: block; }
/* Results tab: hero */
.lab-hero {
background: linear-gradient(180deg, #ffffff 0%, var(--bg) 100%);
padding: 2.5rem 1.5rem 2rem;
border-bottom: 1px solid var(--border);
}
.lab-hero-inner { max-width: 1120px; margin: 0 auto; }
.hero-strategy-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 0.7rem; flex-wrap: wrap; gap: 0.6rem;
}
.strategy-badge {
font-size: 0.78rem; font-weight: 600; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--accent);
padding: 4px 10px; background: rgba(45,156,219,0.08);
border-radius: 4px;
}
.switch-btn {
background: var(--surface); border: 1px solid var(--border);
color: var(--text-2); font-weight: 500; font-size: 0.88rem;
padding: 0.45rem 0.9rem; border-radius: 8px;
cursor: pointer; transition: all 0.1s ease;
display: flex; align-items: center; gap: 0.4rem;
}
.switch-btn:hover { border-color: var(--accent); color: var(--accent); }
.switch-btn .arrow { font-family: var(--mono); }
.lab-hero h1 {
font-size: 2rem; font-weight: 700; letter-spacing: -0.02em;
margin: 0 0 0.5rem; color: var(--text);
}
.lab-hero .lead {
font-size: 1rem; color: var(--text-2);
max-width: 740px; margin: 0; line-height: 1.55;
}
.bankroll-row {
margin-top: 1.4rem; padding: 0.9rem 1rem;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius);
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem;
}
.bankroll-lbl { font-size: 0.9rem; color: var(--text-2); font-weight: 500; }
.bankroll-choices { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.bankroll-choices button {
background: var(--surface); border: 1px solid var(--border);
color: var(--text-2); font-size: 0.9rem; font-weight: 600;
padding: 0.35rem 0.75rem; border-radius: 6px; cursor: pointer;
font-family: var(--mono);
transition: all 0.1s ease;
}
.bankroll-choices button:hover { border-color: var(--border-2); color: var(--text); }
.bankroll-choices button.active { background: var(--accent); border-color: var(--accent); color: white; }
.bankroll-note {
flex-basis: 100%; font-size: 0.8rem; color: var(--text-3);
margin-top: 0.2rem;
}
/* Verdict section */
.verdict-section { padding: 2.2rem 1.5rem; background: var(--surface); border-bottom: 1px solid var(--border); }
.verdict-inner { max-width: 1120px; margin: 0 auto; }
.verdict-card {
display: flex; gap: 1.25rem; align-items: center;
padding: 1.4rem 1.6rem; border-radius: var(--radius-lg);
margin-bottom: 1.25rem;
border: 1px solid var(--border); background: var(--surface-2);
}
.verdict-card.win { background: var(--pos-soft); border-color: rgba(22,163,74,0.3); }
.verdict-card.loss { background: var(--neg-soft); border-color: rgba(220,38,38,0.3); }
.verdict-card.flat { background: var(--warn-soft); border-color: rgba(217,119,6,0.3); }
.verdict-icon {
font-size: 2.4rem; line-height: 1;
width: 3.8rem; height: 3.8rem;
display: flex; align-items: center; justify-content: center;
background: var(--surface); border-radius: 50%; flex-shrink: 0;
box-shadow: var(--shadow);
}
.verdict-card.win .verdict-icon { color: var(--pos); }
.verdict-card.loss .verdict-icon { color: var(--neg); }
.verdict-card.flat .verdict-icon { color: var(--warn); }
.verdict-label {
font-size: 1.35rem; font-weight: 700; letter-spacing: -0.01em;
line-height: 1.2;
}
.verdict-card.win .verdict-label { color: var(--pos); }
.verdict-card.loss .verdict-label { color: var(--neg); }
.verdict-card.flat .verdict-label { color: var(--warn); }
.verdict-detail { color: var(--text-2); margin-top: 0.3rem; font-size: 0.94rem; }
.verdict-stats {
display: grid; gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
margin-bottom: 1.25rem;
}
.vstat {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1rem 1.1rem;
}
.vstat-val {
font-family: var(--mono); font-size: 1.4rem; font-weight: 700;
color: var(--text); letter-spacing: -0.01em;
}
.vstat-val.pos { color: var(--pos); }
.vstat-val.neg { color: var(--neg); }
.vstat-lbl {
font-size: 0.78rem; color: var(--text-3); margin-top: 0.2rem;
font-weight: 500;
}
.verdict-explainer {
font-size: 0.93rem; line-height: 1.6; color: var(--text-2);
max-width: 820px; margin: 0;
padding: 1rem 1.1rem; background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius);
}
/* Trades table */
.trades-section { padding: 2.2rem 1.5rem; border-bottom: 1px solid var(--border); }
.trades-inner { max-width: 1120px; margin: 0 auto; }
.section-header { margin-bottom: 1.25rem; }
.section-header h2 { font-size: 1.2rem; font-weight: 700; margin: 0 0 0.3rem; letter-spacing: -0.01em; }
.section-header p { color: var(--text-2); font-size: 0.93rem; margin: 0 0 0.9rem; max-width: 740px; }
.trade-filter { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.filter-btn {
background: var(--surface); border: 1px solid var(--border);
color: var(--text-2); font-size: 0.85rem; font-weight: 500;
padding: 0.4rem 0.85rem; border-radius: 999px; cursor: pointer;
display: inline-flex; gap: 0.4rem; align-items: center;
}
.filter-btn:hover { border-color: var(--border-2); color: var(--text); }
.filter-btn.active { background: var(--text); color: white; border-color: var(--text); }
.filter-btn span {
font-family: var(--mono); font-size: 0.75rem;
color: var(--text-3); font-weight: 600;
}
.filter-btn.active span { color: rgba(255,255,255,0.85); }
.trade-list { display: flex; flex-direction: column; gap: 0.5rem; }
.trade-row {
display: grid; grid-template-columns: 1fr auto auto; gap: 1rem;
align-items: center;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.85rem 1.1rem;
}
.trade-row.win { border-left: 3px solid var(--pos); }
.trade-row.loss { border-left: 3px solid var(--neg); }
.trade-row.skip { opacity: 0.72; border-left: 3px solid var(--border-2); }
.trade-event { min-width: 0; }
.trade-title {
font-size: 0.95rem; font-weight: 500; color: var(--text);
margin-bottom: 0.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.trade-meta { font-size: 0.8rem; color: var(--text-3); }
.trade-action {
font-family: var(--mono); font-size: 0.82rem; color: var(--text-2);
text-align: right; white-space: nowrap;
}
.trade-result {
font-family: var(--mono); font-size: 1rem; font-weight: 700;
min-width: 90px; text-align: right;
}
.trade-result.pos { color: var(--pos); }
.trade-result.neg { color: var(--neg); }
.trade-result.neutral { color: var(--text-3); font-weight: 400; font-size: 0.85rem; }
.trade-show-more {
background: var(--surface); border: 1px dashed var(--border-2);
color: var(--text-2); font-size: 0.88rem;
padding: 0.8rem 1.1rem; border-radius: var(--radius);
text-align: center; cursor: pointer;
transition: all 0.1s ease;
}
.trade-show-more:hover { border-color: var(--accent); color: var(--accent); }
.trade-count-footer {
text-align: center; padding: 1rem; margin-top: 0.4rem;
font-size: 0.85rem; color: var(--text-3);
}
.trade-count-footer strong { color: var(--text); font-family: var(--mono); }
.trade-count-footer a { color: var(--accent); text-decoration: underline; }
.trade-count-footer a:hover { color: var(--accent-dark); }
.data-section { padding: 2rem 1.5rem; background: var(--surface); }
.data-inner { max-width: 840px; margin: 0 auto; }
.data-inner h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 0.5rem; }
.data-inner p { color: var(--text-2); font-size: 0.9rem; line-height: 1.6; margin-bottom: 0.8rem; }
.data-inner strong { color: var(--text); }
/* Strategies tab */
.strategies-hero { padding: 2.5rem 1.5rem 1.5rem; border-bottom: 1px solid var(--border); background: linear-gradient(180deg, #ffffff 0%, var(--bg) 100%); }
.strategies-hero-inner { max-width: 1120px; margin: 0 auto; }
.strategies-hero h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; letter-spacing: -0.02em; }
.strategies-hero p { color: var(--text-2); max-width: 720px; margin: 0; font-size: 1rem; }
.strategy-grid-section { padding: 2rem 1.5rem; }
.strategy-grid {
max-width: 1120px; margin: 0 auto;
display: grid; gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.strat-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 1.3rem 1.4rem;
cursor: pointer; transition: all 0.12s ease;
display: flex; flex-direction: column;
}
.strat-card:hover { border-color: var(--accent); box-shadow: var(--shadow-lg); transform: translateY(-2px); }
.strat-card.active { border-color: var(--accent); background: #f0f9ff; }
.strat-card-head {
display: flex; align-items: start; justify-content: space-between;
gap: 0.5rem; margin-bottom: 0.6rem;
}
.strat-card-name {
font-size: 1.05rem; font-weight: 700; color: var(--text);
letter-spacing: -0.01em;
}
.strat-card-badge {
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
padding: 3px 9px; border-radius: 4px; flex-shrink: 0;
}
.strat-card-badge.win { background: var(--pos-soft); color: var(--pos); }
.strat-card-badge.loss { background: var(--neg-soft); color: var(--neg); }
.strat-card-badge.flat { background: var(--warn-soft); color: var(--warn); }
.strat-card-desc {
font-size: 0.88rem; color: var(--text-2);
line-height: 1.5; margin: 0 0 1rem;
}
.strat-card-metric {
font-family: var(--mono); font-size: 1.8rem; font-weight: 700;
letter-spacing: -0.02em; line-height: 1;
}
.strat-card-metric.pos { color: var(--pos); }
.strat-card-metric.neg { color: var(--neg); }
.strat-card-metric.flat { color: var(--warn); }
.strat-card-metric-sub { font-size: 0.78rem; color: var(--text-3); margin-top: 0.2rem; }
.strat-card-stats {
display: flex; gap: 1.1rem; margin-top: 0.9rem;
padding-top: 0.9rem; border-top: 1px solid var(--border);
}
.strat-card-stat { font-size: 0.8rem; color: var(--text-3); }
.strat-card-stat strong { color: var(--text); font-family: var(--mono); font-weight: 600; }
.strat-card-learn {
margin-top: 0.9rem; font-size: 0.82rem; color: var(--accent); font-weight: 500;
}
/* Strategy detail modal */
.modal-wide { max-width: 720px; }
.strategy-detail h2 {
font-size: 1.4rem; font-weight: 700; margin: 0 0 0.4rem;
letter-spacing: -0.01em;
}
.strategy-detail .detail-verdict {
display: inline-block; padding: 4px 12px; border-radius: 999px;
font-weight: 700; font-size: 0.78rem; letter-spacing: 0.05em; text-transform: uppercase;
margin-bottom: 1rem;
}
.strategy-detail .detail-verdict.win { background: var(--pos-soft); color: var(--pos); }
.strategy-detail .detail-verdict.loss { background: var(--neg-soft); color: var(--neg); }
.strategy-detail .detail-verdict.flat { background: var(--warn-soft); color: var(--warn); }
.strategy-detail .detail-rule {
background: var(--surface-2); border: 1px solid var(--border);
padding: 0.85rem 1rem; border-radius: var(--radius);
font-size: 0.92rem; color: var(--text); margin-bottom: 1rem;
}
.strategy-detail .detail-rule strong { font-weight: 600; }
.strategy-detail .detail-section { margin: 1.1rem 0; }
.strategy-detail h3 {
font-size: 0.78rem; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--text-3); font-weight: 600; margin: 0 0 0.4rem;
}
.strategy-detail p { color: var(--text-2); line-height: 1.6; margin: 0 0 0.7rem; font-size: 0.93rem; }
.strategy-detail p strong { color: var(--text); }
.strategy-detail .detail-stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.7rem;
}
.strategy-detail .dstat {
background: var(--surface-2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.7rem 0.85rem;
}
.strategy-detail .dstat-val { font-family: var(--mono); font-size: 1.15rem; font-weight: 700; }
.strategy-detail .dstat-val.pos { color: var(--pos); }
.strategy-detail .dstat-val.neg { color: var(--neg); }
.strategy-detail .dstat-lbl { font-size: 0.72rem; color: var(--text-3); margin-top: 0.1rem; }
.strategy-detail .cta-row {
margin-top: 1.3rem; display: flex; gap: 0.6rem; flex-wrap: wrap;
}
.strategy-detail .cta-primary {
background: var(--accent); color: white; border: 0;
padding: 0.7rem 1.3rem; border-radius: 8px;
font-weight: 600; font-size: 0.95rem; cursor: pointer;
transition: background 0.1s ease;
}
.strategy-detail .cta-primary:hover { background: var(--accent-dark); }
.strategy-detail .cta-secondary {
background: var(--surface-2); color: var(--text-2); border: 1px solid var(--border);
padding: 0.7rem 1.3rem; border-radius: 8px;
font-weight: 500; font-size: 0.95rem; cursor: pointer;
}
.strategy-detail .cta-secondary:hover { color: var(--text); border-color: var(--border-2); }
+675
View File
@@ -0,0 +1,675 @@
/*
* Polymarket Strategy Lab pure browser-side backtester.
*
* Loads 100+ REAL resolved Polymarket categorical events from docs/data/
* historical-events.json, runs five different strategies against them,
* and shows you honest results no cherry-picking.
*
* Each strategy is a pure function: given an event (outcomes + their
* last-trade prices + who actually won), it returns what it would have
* bought, how much it paid, and how much it got back. The backtest runner
* tallies these across every event.
*
* The strategies live here, open and readable. You can read exactly what
* each rule is doing.
*/
// ============================== DATA ====================================
const DATA_URL = "./data/historical-events.json";
// ========================== STRATEGIES ==================================
//
// A strategy is: strategy(event) -> { action, cost, payout, note }
// action: "trade" if we bought anything, "skip" if we passed
// cost: total dollars paid (at last-trade prices)
// payout: total dollars received after resolution
// note: short plain-English description of what happened
//
// Every strategy bets into the same event in its own way. Results are tallied
// across all events in the dataset.
const STRATEGIES = [
{
key: "basket-arb",
name: "Basket Arbitrage",
oneLiner: "Buy one share of every outcome — but only when the total cost is under $1.",
rule: "If the sum of every outcome's last trade price is below $1.00, buy one share of every outcome. Otherwise skip. Exactly one outcome will win and pay $1, so you profit the gap.",
why: "This is the textbook risk-free trade. It's the one real arbitrage on prediction markets. The question is: does it ever actually trigger in practice, on resting prices, for a retail bot that isn't co-located next to the exchange? The historical data tells the truth.",
run(ev, window) {
const prices = ev.outcomes.map(o => priceAt(o, ev, window));
if (prices.some(p => p == null || p <= 0 || p >= 1)) {
return { action: "skip", cost: 0, payout: 0, sum: null, note: "No price data at this window for at least one outcome." };
}
const sum = prices.reduce((a, b) => a + b, 0);
if (sum >= 1.0) {
return { action: "skip", cost: 0, payout: 0, sum, note: `Total cost $${sum.toFixed(3)}, above $1. No arbitrage — skipped.` };
}
return { action: "trade", cost: sum, payout: 1.0, sum, note: `Total cost $${sum.toFixed(3)}. Bought the full set — guaranteed $1 payout.` };
},
},
{
key: "favorite",
name: "Bet the Favorite",
oneLiner: "On every event, buy the single outcome the market thinks is most likely.",
rule: "For each event, buy one share of whichever outcome has the highest price at the chosen time window. If that outcome wins you get $1, otherwise $0.",
why: "Conventional wisdom: the market knows. If the favorite wins often enough you make money; if favorites are over-priced you lose. Tests whether Polymarket's top-line pricing has any slack.",
run(ev, window) {
let best = null, bestPrice = -1;
for (const o of ev.outcomes) {
const p = priceAt(o, ev, window);
if (p == null) continue;
if (p > bestPrice) { best = o; bestPrice = p; }
}
if (!best || bestPrice <= 0) return { action: "skip", cost: 0, payout: 0, note: "No valid prices." };
return {
action: "trade",
cost: bestPrice,
payout: best.yes_final_price,
note: `Bought "${best.name}" at $${bestPrice.toFixed(3)}. ${best.yes_final_price === 1 ? "Won — payout $1." : "Lost — payout $0."}`,
};
},
},
{
key: "longshot",
name: "Bet the Longshot",
oneLiner: "On every event, buy the cheapest outcome. Pray it wins.",
rule: "For each event, buy one share of whichever outcome has the lowest positive price at the chosen window.",
why: "The market prices longshots low for a reason. But if underdogs win more often than prices imply (a classic bias), this pays. Direct test.",
run(ev, window) {
let best = null, bestPrice = Infinity;
for (const o of ev.outcomes) {
const p = priceAt(o, ev, window);
if (p == null || p <= 0) continue;
if (p < bestPrice) { best = o; bestPrice = p; }
}
if (!best) return { action: "skip", cost: 0, payout: 0, note: "No valid prices." };
return {
action: "trade",
cost: bestPrice,
payout: best.yes_final_price,
note: `Bought "${best.name}" at $${bestPrice.toFixed(3)}. ${best.yes_final_price === 1 ? "Won — payout $1." : "Lost — payout $0."}`,
};
},
},
{
key: "equal-split",
name: "Equal Split",
oneLiner: "Buy one share of every outcome, always — no matter the price.",
rule: "For each event, buy one share of every outcome. You pay the sum of prices. You receive $1 (exactly one wins).",
why: "Basket Arbitrage without the safety condition. Every event is a tiny guaranteed loss equal to the &ldquo;vig&rdquo; — the amount by which Polymarket's prices overshoot $1. A baseline for what the market's rounding costs.",
run(ev, window) {
const prices = ev.outcomes.map(o => priceAt(o, ev, window));
// If any outcome lacks price data at this window, skip (we can't evaluate)
if (prices.some(p => p == null)) {
return { action: "skip", cost: 0, payout: 0, sum: null, note: "No price data at this window for at least one outcome." };
}
if (prices.some(p => p == null || p <= 0)) {
return { action: "skip", cost: 0, payout: 0, note: "Missing prices." };
}
const cost = prices.reduce((a, b) => a + b, 0);
return { action: "trade", cost, payout: 1.0, note: `Paid $${cost.toFixed(3)} for every outcome. Guaranteed $1 payout.` };
},
},
{
key: "top-three",
name: "Top Three",
oneLiner: "Buy the three outcomes the market thinks are most likely. Win if any of them wins.",
rule: "For each event with 3+ outcomes, buy one share of the three highest-priced outcomes at the chosen window. Pay the sum. Win $1 if any of those three wins.",
why: "A hedged bet — buying most of the probability mass but skipping the tail. If the hit rate is high enough, it pays.",
run(ev, window) {
const priced = ev.outcomes.map(o => ({ o, p: priceAt(o, ev, window) })).filter(x => x.p != null && x.p > 0);
if (priced.length < 3) return { action: "skip", cost: 0, payout: 0, note: "Fewer than 3 priced outcomes." };
const top = [...priced].sort((a, b) => b.p - a.p).slice(0, 3);
const cost = top.reduce((s, x) => s + x.p, 0);
const won = top.some(x => x.o.yes_final_price === 1);
return {
action: "trade",
cost,
payout: won ? 1.0 : 0.0,
note: `Bought top 3 (total $${cost.toFixed(3)}). ${won ? "One won — payout $1." : "None won — payout $0."}`,
};
},
},
];
// ========================== BACKTEST RUNNER =============================
function runBacktest(strategy, events, window) {
const rows = [];
let totalCost = 0, totalPayout = 0;
let trades = 0, wins = 0, losses = 0, skipped = 0;
for (const ev of events) {
const result = strategy.run(ev, window);
const pnl = (result.payout || 0) - (result.cost || 0);
const row = { event: ev, result, pnl };
rows.push(row);
if (result.action === "trade") {
trades += 1;
totalCost += result.cost || 0;
totalPayout += result.payout || 0;
if (pnl > 0) wins += 1;
else if (pnl < 0) losses += 1;
} else {
skipped += 1;
}
}
const pnlAbs = totalPayout - totalCost;
const roi = totalCost > 0 ? pnlAbs / totalCost : 0;
const winRate = trades > 0 ? wins / trades : null;
return {
rows,
totalCost, totalPayout, pnlAbs, roi,
trades, wins, losses, skipped,
winRate,
eventCount: events.length,
};
}
// ========================== STATE =======================================
const state = {
events: [],
results: {},
activeKey: "basket-arb",
tradeFilter: "all",
bankroll: 1000,
priceWindow: "24h", // key from WINDOWS below
};
// Time windows: how many seconds before close to read the price.
const WINDOWS = {
"close": { label: "at close", seconds: 0 },
"1h": { label: "1h before close", seconds: 3600 },
"6h": { label: "6h before close", seconds: 6*3600 },
"24h": { label: "24h before close", seconds: 24*3600 },
"3d": { label: "3 days before close", seconds: 3*24*3600 },
"7d": { label: "7 days before close", seconds: 7*24*3600 },
};
/**
* Return the actual price a trader would have seen on Polymarket at a specific
* time. Uses real historical price data pulled from Polymarket's public CLOB
* price-history endpoint not estimates.
*/
function priceAt(outcome, ev, windowKey) {
const hist = outcome.history;
if (!hist || !hist.length) return null;
const w = WINDOWS[windowKey] || WINDOWS["close"];
const closeTs = ev._closeTs; // precomputed
if (closeTs == null) return null;
const targetTs = closeTs - w.seconds;
// If the target is before any recorded data, no price
if (hist[0].t > targetTs) return null;
// Binary search for the last point with t <= targetTs
let lo = 0, hi = hist.length - 1;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (hist[mid].t <= targetTs) lo = mid;
else hi = mid - 1;
}
return hist[lo].p;
}
// ========================== DOM =========================================
const $ = (s) => document.querySelector(s);
const el = {
tabResults: $("#tab-results"),
tabStrategies: $("#tab-strategies"),
panelResults: $("#panel-results"),
panelStrategies:$("#panel-strategies"),
eventCountInline: $("#event-count-inline"),
eventCountStrat: $("#event-count-strat"),
activeLabel: $("#active-strategy-label"),
activeName: $("#active-strategy-name"),
activeDesc: $("#active-strategy-desc"),
switchBtn: $("#switch-btn"),
verdictCard: $("#verdict-card"),
verdictIcon: $("#verdict-icon"),
verdictLabel: $("#verdict-label"),
verdictDetail: $("#verdict-detail"),
vstatPnl: $("#vstat-pnl"),
vstatPnlLbl: $("#vstat-pnl-lbl"),
vstatRoi: $("#vstat-roi"),
vstatTrades: $("#vstat-trades"),
vstatWinrate: $("#vstat-winrate"),
vstatAnnual: $("#vstat-annual"),
bankrollChoices: $("#bankroll-choices"),
bankrollNote: $("#bankroll-note"),
verdictExplainer: $("#verdict-explainer"),
cntAll: $("#cnt-all"),
cntTrades: $("#cnt-trades"),
cntWins: $("#cnt-wins"),
cntLosses: $("#cnt-losses"),
cntSkipped: $("#cnt-skipped"),
tradeList: $("#trade-list"),
strategyGrid: $("#strategy-grid"),
modal: $("#strategy-modal"),
modalContent: $("#strategy-modal-content"),
};
// ========================== BOOT ========================================
boot().catch(err => {
console.error("lab boot failed", err);
el.verdictLabel.textContent = "Couldn't load historical data";
el.verdictDetail.textContent = String(err.message || err);
});
async function boot() {
const resp = await fetch(DATA_URL + "?t=" + Date.now());
if (!resp.ok) throw new Error("historical-events.json " + resp.status);
const payload = await resp.json();
state.events = Array.isArray(payload?.events) ? payload.events : [];
if (!state.events.length) throw new Error("No events found in dataset");
// Precompute the close timestamp (seconds since epoch) for each event, so
// priceAt() can do a cheap binary search per lookup.
for (const ev of state.events) {
const raw = ev.closed_time || ev.end_date || "";
const iso = String(raw).replace(" +00", "+00:00").replace("Z", "+00:00");
const d = new Date(iso);
ev._closeTs = isNaN(d.getTime()) ? null : Math.floor(d.getTime() / 1000);
}
const ends = state.events
.map(e => e._closeTs ? new Date(e._closeTs * 1000) : null)
.filter(d => d != null)
.sort((a, b) => a - b);
state.spanFirst = ends[0];
state.spanLast = ends[ends.length - 1];
state.spanDays = Math.max(1, (state.spanLast - state.spanFirst) / (1000 * 60 * 60 * 24));
el.eventCountInline.textContent = `${state.events.length} events · ${formatSpanDescription(state.spanFirst, state.spanLast)}`;
el.eventCountStrat.textContent = state.events.length;
rerunBacktests();
wireInteractions();
renderStrategyGrid();
renderActiveStrategy();
}
function rerunBacktests() {
for (const s of STRATEGIES) {
state.results[s.key] = runBacktest(s, state.events, state.priceWindow);
}
}
function formatSpanDescription(first, last, withMonths = true) {
if (!first || !last) return "";
const fmt = { month: "short", year: "numeric" };
const range = `${first.toLocaleDateString(undefined, fmt)} ${last.toLocaleDateString(undefined, fmt)}`;
if (!withMonths) return range;
const months = (state.spanDays / 30).toFixed(1);
return `${range} (${months} months)`;
}
function pluralize(n, word) {
return n === 1 ? `1 ${word}` : `${n} ${word}s`;
}
function wireInteractions() {
// Tabs
el.tabResults.addEventListener("click", () => switchTab("results"));
el.tabStrategies.addEventListener("click", () => switchTab("strategies"));
// "Change strategy" button on results page -> jumps to strategies tab
el.switchBtn.addEventListener("click", () => switchTab("strategies"));
// Trade filter buttons
document.querySelectorAll(".filter-btn").forEach(btn => {
btn.addEventListener("click", () => {
state.tradeFilter = btn.dataset.filter;
document.querySelectorAll(".filter-btn").forEach(b => b.classList.toggle("active", b === btn));
renderTradeList();
});
});
// CSV download
const dl = document.getElementById("csv-download");
if (dl) dl.addEventListener("click", (e) => { e.preventDefault(); downloadCsv(); });
// Bankroll selector
el.bankrollChoices.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-bankroll]");
if (!btn) return;
state.bankroll = parseInt(btn.dataset.bankroll, 10) || 1000;
[...el.bankrollChoices.querySelectorAll("button")].forEach(b => b.classList.toggle("active", b === btn));
renderActiveStrategy();
renderStrategyGrid();
});
// Price-window selector
const windowChoices = document.getElementById("window-choices");
windowChoices.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-window]");
if (!btn) return;
state.priceWindow = btn.dataset.window;
[...windowChoices.querySelectorAll("button")].forEach(b => b.classList.toggle("active", b === btn));
rerunBacktests();
renderActiveStrategy();
renderStrategyGrid();
});
// Modal close
el.modal.addEventListener("click", (e) => {
if (e.target.dataset?.close !== undefined) el.modal.hidden = true;
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") el.modal.hidden = true;
});
}
function switchTab(which) {
const isResults = which === "results";
el.tabResults.classList.toggle("active", isResults);
el.tabStrategies.classList.toggle("active", !isResults);
el.panelResults.classList.toggle("active", isResults);
el.panelStrategies.classList.toggle("active", !isResults);
window.scrollTo({ top: 0, behavior: "smooth" });
}
// ========================== RENDER: RESULTS TAB =========================
function renderActiveStrategy() {
const strategy = STRATEGIES.find(s => s.key === state.activeKey);
if (!strategy) return;
const result = state.results[strategy.key];
el.activeLabel.textContent = "Active strategy";
el.activeName.textContent = strategy.name;
el.activeDesc.textContent = strategy.oneLiner;
renderVerdict(strategy, result);
renderTradeList();
}
function verdictClass(result) {
const pnl = result.pnlAbs;
if (Math.abs(pnl) < 0.005) return "flat";
return pnl > 0 ? "win" : "loss";
}
function renderVerdict(strategy, result) {
const cls = verdictClass(result);
el.verdictCard.className = "verdict-card " + cls;
el.verdictIcon.textContent = cls === "win" ? "✓" : cls === "loss" ? "✗" : "≈";
const { roi, trades, wins, losses, eventCount } = result;
const totalPnl = roi * state.bankroll * trades; // bet $bankroll each trade, PnL per trade = roi*bankroll
const months = (state.spanDays / 30).toFixed(1);
const span = formatSpanDescription(state.spanFirst, state.spanLast, false);
const firedN = pluralize(trades, "time");
if (trades === 0) {
el.verdictLabel.textContent = "Strategy never triggered";
el.verdictDetail.textContent = `Over ${months} months of real Polymarket events (${span}), this strategy's rule never fired even once. Pure arbitrage on resting prices almost never exists — bots eat any gap in milliseconds.`;
} else if (cls === "win") {
el.verdictLabel.textContent = "Made money on this dataset";
el.verdictDetail.textContent = `Over ${months} months (${span}) this strategy fired ${firedN} across ${eventCount} events. ${wins} wins, ${losses} losses. At a $${state.bankroll.toLocaleString()} bankroll per trade, total profit was ${formatSignedDollar(totalPnl)}.`;
} else if (cls === "loss") {
el.verdictLabel.textContent = "Lost money on this dataset";
el.verdictDetail.textContent = `Over ${months} months (${span}) this strategy fired ${firedN} across ${eventCount} events. ${wins} wins, ${losses} losses. At a $${state.bankroll.toLocaleString()} bankroll per trade, total loss was ${formatSignedDollar(totalPnl)}.`;
} else {
el.verdictLabel.textContent = "Roughly break-even";
el.verdictDetail.textContent = `Over ${months} months (${span}) this strategy fired ${firedN} across ${eventCount} events. Total profit with a $${state.bankroll.toLocaleString()} bankroll was ${formatSignedDollar(totalPnl)} — essentially nothing.`;
}
el.vstatPnl.textContent = formatSignedDollar(totalPnl);
el.vstatPnl.className = "vstat-val " + (totalPnl > 0.005 ? "pos" : totalPnl < -0.005 ? "neg" : "");
el.vstatPnlLbl.textContent = `Total profit at $${state.bankroll.toLocaleString()} per trade`;
el.vstatRoi.textContent = trades > 0 ? formatSignedPct(roi) : "—";
el.vstatRoi.className = "vstat-val " + (roi > 0.0001 ? "pos" : roi < -0.0001 ? "neg" : "");
el.vstatTrades.textContent = `${trades} of ${eventCount}`;
el.vstatTrades.className = "vstat-val";
el.vstatWinrate.textContent = trades > 0 ? `${(result.winRate * 100).toFixed(1)}%` : "—";
el.vstatWinrate.className = "vstat-val";
// Annualized profit: scale the total by (365 / span)
const annualPnl = totalPnl * (365 / state.spanDays);
el.vstatAnnual.textContent = trades > 0 ? formatSignedDollar(annualPnl) : "—";
el.vstatAnnual.className = "vstat-val " + (annualPnl > 0.005 ? "pos" : annualPnl < -0.005 ? "neg" : "");
el.verdictExplainer.innerHTML = strategy.why;
}
function renderTradeList() {
const strategy = STRATEGIES.find(s => s.key === state.activeKey);
const result = state.results[strategy.key];
const all = result.rows;
const filters = {
all: (r) => true,
trades: (r) => r.result.action === "trade",
wins: (r) => r.result.action === "trade" && r.pnl > 0,
losses: (r) => r.result.action === "trade" && r.pnl < 0,
skipped: (r) => r.result.action === "skip",
};
const filtered = all.filter(filters[state.tradeFilter]);
// counts
el.cntAll.textContent = all.length;
el.cntTrades.textContent = all.filter(filters.trades).length;
el.cntWins.textContent = all.filter(filters.wins).length;
el.cntLosses.textContent = all.filter(filters.losses).length;
el.cntSkipped.textContent = all.filter(filters.skipped).length;
// sort: trades first (by |pnl| desc), then skipped
filtered.sort((a, b) => {
const aAct = a.result.action === "trade" ? 0 : 1;
const bAct = b.result.action === "trade" ? 0 : 1;
if (aAct !== bAct) return aAct - bAct;
return Math.abs(b.pnl) - Math.abs(a.pnl);
});
el.tradeList.innerHTML = "";
if (!filtered.length) {
const empty = document.createElement("div");
empty.className = "trade-show-more";
empty.style.cursor = "default";
empty.textContent = "No trades match this filter.";
el.tradeList.appendChild(empty);
return;
}
// Show every row. If you claim N trades, you show N trades.
for (const r of filtered) {
el.tradeList.appendChild(renderTradeRow(r));
}
const footer = document.createElement("div");
footer.className = "trade-count-footer";
footer.innerHTML = `Showing all <strong>${filtered.length}</strong> ${filtered.length === 1 ? "row" : "rows"} · <a href="#" id="csv-download">download as CSV</a>`;
el.tradeList.appendChild(footer);
const dl = document.getElementById("csv-download");
if (dl) dl.addEventListener("click", (e) => { e.preventDefault(); downloadCsv(); });
}
function downloadCsv() {
const strategy = STRATEGIES.find(s => s.key === state.activeKey);
const result = state.results[strategy.key];
const rows = [["event_title", "neg_risk", "num_outcomes", "action", "cost", "payout", "pnl", "note"]];
for (const r of result.rows) {
rows.push([
r.event.title,
String(r.event.neg_risk),
String(r.event.num_outcomes),
r.result.action,
(r.result.cost || 0).toFixed(4),
(r.result.payout || 0).toFixed(4),
r.pnl.toFixed(4),
(r.result.note || "").replace(/[\r\n]+/g, " "),
]);
}
const csv = rows.map(row => row.map(v => {
const s = String(v);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `polymarket-backtest-${strategy.key}.csv`;
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); document.body.removeChild(a); }, 0);
}
function renderTradeRow(r) {
const row = document.createElement("div");
const didTrade = r.result.action === "trade";
const cls = didTrade
? (r.pnl > 0 ? "win" : r.pnl < 0 ? "loss" : "skip")
: "skip";
row.className = "trade-row " + cls;
// Scale by bankroll: if backtest cost was $0.40 for one share, and bankroll is
// $1000, the trader would buy $1000/$0.40 = 2500 units — scaled pnl = roi * bankroll.
const unitRoi = r.result.cost > 0 ? (r.pnl / r.result.cost) : 0;
const scaledCost = didTrade ? state.bankroll : 0;
const scaledPayout = didTrade ? state.bankroll * (1 + unitRoi) : 0;
const scaledPnl = scaledPayout - scaledCost;
const meta = didTrade
? `paid $${scaledCost.toLocaleString(undefined, {maximumFractionDigits:2})} → got back $${scaledPayout.toLocaleString(undefined, {maximumFractionDigits:2})}`
: (r.result.note || "Strategy did not trade this event.");
const resultCell = didTrade
? (scaledPnl > 0.005
? `<span class="trade-result pos">+$${scaledPnl.toLocaleString(undefined, {maximumFractionDigits:2})}</span>`
: scaledPnl < -0.005
? `<span class="trade-result neg">-$${Math.abs(scaledPnl).toLocaleString(undefined, {maximumFractionDigits:2})}</span>`
: `<span class="trade-result neutral">$0.00</span>`)
: `<span class="trade-result neutral">skipped</span>`;
row.innerHTML = `
<div class="trade-event">
<div class="trade-title">${escapeHtml(r.event.title)}</div>
<div class="trade-meta">${escapeHtml(meta)}</div>
</div>
<div class="trade-action">${escapeHtml(didTrade ? r.result.note : "")}</div>
${resultCell}
`;
return row;
}
// ========================== RENDER: STRATEGIES TAB ======================
function renderStrategyGrid() {
el.strategyGrid.innerHTML = "";
for (const s of STRATEGIES) {
const r = state.results[s.key];
const cls = verdictClass(r);
const card = document.createElement("div");
card.className = "strat-card" + (s.key === state.activeKey ? " active" : "");
const metric = r.trades > 0 ? formatSignedPct(r.roi) : "never fired";
const totalScaledPnl = r.roi * state.bankroll * r.trades;
const metricSub = r.trades > 0
? `${formatSignedDollar(totalScaledPnl)} total at $${state.bankroll.toLocaleString()}/trade · ${r.trades} trades`
: `skipped all ${r.eventCount} events`;
const verdictLabel = r.trades === 0 ? "INACTIVE"
: cls === "win" ? "PROFITABLE"
: cls === "loss" ? "LOSES MONEY"
: "BREAK-EVEN";
card.innerHTML = `
<div class="strat-card-head">
<div class="strat-card-name">${escapeHtml(s.name)}</div>
<div class="strat-card-badge ${cls}">${verdictLabel}</div>
</div>
<p class="strat-card-desc">${escapeHtml(s.oneLiner)}</p>
<div class="strat-card-metric ${cls}">${metric}</div>
<div class="strat-card-metric-sub">${escapeHtml(metricSub)}</div>
<div class="strat-card-stats">
<div class="strat-card-stat">trades: <strong>${r.trades}</strong></div>
<div class="strat-card-stat">wins: <strong>${r.wins}</strong></div>
<div class="strat-card-stat">losses: <strong>${r.losses}</strong></div>
</div>
<div class="strat-card-learn">Learn more & use this strategy </div>
`;
card.addEventListener("click", () => openStrategyModal(s));
el.strategyGrid.appendChild(card);
}
}
function openStrategyModal(s) {
const r = state.results[s.key];
const cls = verdictClass(r);
const verdictLabel = r.trades === 0 ? "STRATEGY NEVER FIRED"
: cls === "win" ? "PROFITABLE ON THIS DATASET"
: cls === "loss" ? "LOSES MONEY ON THIS DATASET"
: "ROUGHLY BREAK-EVEN";
el.modalContent.innerHTML = `
<div class="strategy-detail">
<h2>${escapeHtml(s.name)}</h2>
<div class="detail-verdict ${cls}">${verdictLabel}</div>
<div class="detail-rule"><strong>The rule:</strong> ${s.rule}</div>
<div class="detail-section">
<h3>Why this strategy?</h3>
<p>${s.why}</p>
</div>
<div class="detail-section">
<h3>Results on ${r.eventCount} real resolved events</h3>
<div class="detail-stats">
<div class="dstat">
<div class="dstat-val ${r.pnlAbs > 0 ? 'pos' : r.pnlAbs < 0 ? 'neg' : ''}">${formatSignedDollar(r.pnlAbs)}</div>
<div class="dstat-lbl">total profit</div>
</div>
<div class="dstat">
<div class="dstat-val ${r.roi > 0 ? 'pos' : r.roi < 0 ? 'neg' : ''}">${r.trades > 0 ? formatSignedPct(r.roi) : '—'}</div>
<div class="dstat-lbl">ROI per dollar</div>
</div>
<div class="dstat">
<div class="dstat-val">${r.trades}</div>
<div class="dstat-lbl">trades taken</div>
</div>
<div class="dstat">
<div class="dstat-val">${r.trades > 0 ? (r.winRate * 100).toFixed(1) + '%' : '—'}</div>
<div class="dstat-lbl">win rate</div>
</div>
</div>
</div>
<div class="cta-row">
<button type="button" class="cta-primary" id="use-strategy">Run this strategy on Results tab</button>
<button type="button" class="cta-secondary" data-close>Close</button>
</div>
</div>
`;
el.modal.hidden = false;
document.getElementById("use-strategy").addEventListener("click", () => {
state.activeKey = s.key;
state.tradeFilter = "all";
document.querySelectorAll(".filter-btn").forEach(b => b.classList.toggle("active", b.dataset.filter === "all"));
renderActiveStrategy();
renderStrategyGrid();
el.modal.hidden = true;
switchTab("results");
});
}
// ========================== UTILS =======================================
function formatSignedDollar(x) {
const sign = x >= 0 ? "+" : "";
return sign + "$" + Math.abs(x).toFixed(2);
}
function formatSignedPct(x) {
const sign = x >= 0 ? "+" : "";
return sign + Math.abs(x * 100).toFixed(2) + "%";
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
+396
View File
@@ -0,0 +1,396 @@
/* Polymarket-inspired layout for a portfolio arb-scanner demo. */
:root {
--bg: #f7f8fa;
--surface: #ffffff;
--surface-2: #f1f3f6;
--border: #e5e7eb;
--border-2: #d1d5db;
--text: #0f172a;
--text-2: #4b5563;
--text-3: #6b7280;
--accent: #2d9cdb;
--accent-dark:#1e7fb8;
--pos: #16a34a;
--pos-soft: #dcfce7;
--neg: #dc2626;
--neg-soft: #fee2e2;
--warn: #d97706;
--warn-soft: #fef3c7;
--radius: 10px;
--radius-lg: 14px;
--shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 3px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 4px 10px rgba(15, 23, 42, 0.06), 0 2px 4px rgba(15, 23, 42, 0.04);
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--sans: "Inter", -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
line-height: 1.5;
font-size: 15px;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ---------- landing-page shared styles ---------- */
.container { max-width: 1120px; margin: 0 auto; padding: 2rem 1.5rem; }
header.hero { padding: 4rem 1.5rem 3rem; background: linear-gradient(180deg, #ffffff 0%, var(--bg) 100%); border-bottom: 1px solid var(--border); }
.hero-inner { max-width: 1120px; margin: 0 auto; padding: 0 1.5rem; }
.hero h1 { font-size: 2.5rem; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 0.5rem; }
.hero p.tagline { font-size: 1.08rem; color: var(--text-2); max-width: 720px; margin: 0 0 1.5rem; }
.btn-row { display: flex; gap: 0.6rem; flex-wrap: wrap; margin-top: 1.25rem; }
.btn { display: inline-block; padding: 0.6rem 1.15rem; border-radius: 8px; background: var(--surface); border: 1px solid var(--border); color: var(--text); font-weight: 500; font-size: 0.95rem; transition: all 0.12s ease; }
.btn:hover { text-decoration: none; border-color: var(--border-2); transform: translateY(-1px); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
.btn.primary:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.badges { display: flex; gap: 0.4rem; margin-top: 0.8rem; flex-wrap: wrap; }
.badge { display: inline-block; padding: 3px 10px; border-radius: 4px; background: var(--surface); border: 1px solid var(--border); color: var(--text-2); font-size: 0.78rem; font-family: var(--mono); }
.badge.pos { color: var(--pos); border-color: rgba(22, 163, 74, 0.3); background: var(--pos-soft); }
section { padding: 3rem 1.5rem; border-bottom: 1px solid var(--border); background: var(--bg); }
section:nth-of-type(even) { background: var(--surface); }
section h2 { font-size: 1.4rem; font-weight: 600; margin: 0 0 1rem; letter-spacing: -0.01em; }
section h2 .rule { display: inline-block; width: 2rem; height: 2px; background: var(--accent); vertical-align: middle; margin-right: 0.6rem; }
.grid-2 { display: grid; grid-template-columns: 1fr; gap: 1.25rem; }
@media (min-width: 780px) { .grid-2 { grid-template-columns: 1fr 1fr; } }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.1rem 1.35rem; box-shadow: var(--shadow); }
.card h3 { margin: 0 0 0.5rem; font-size: 1rem; font-weight: 600; }
.card p { margin: 0; color: var(--text-2); font-size: 0.94rem; }
pre, code { font-family: var(--mono); }
code { background: var(--surface-2); padding: 2px 6px; border-radius: 4px; font-size: 0.88em; }
pre { background: var(--surface); border: 1px solid var(--border); padding: 0.95rem 1.1rem; border-radius: var(--radius); overflow-x: auto; font-size: 0.86rem; box-shadow: var(--shadow); }
pre code { background: transparent; padding: 0; }
.arch-diagram { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem; font-family: var(--mono); font-size: 0.78rem; color: var(--text-2); white-space: pre; overflow-x: auto; box-shadow: var(--shadow); }
.metrics-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.9rem; margin-top: 1.25rem; }
.metric { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; text-align: center; box-shadow: var(--shadow); }
.metric .val { font-size: 1.8rem; font-weight: 700; color: var(--accent); font-family: var(--mono); }
.metric .label { display: block; font-size: 0.78rem; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; margin-top: 0.2rem; font-weight: 500; }
footer { padding: 2.5rem 1.5rem; text-align: center; color: var(--text-3); font-size: 0.85rem; background: var(--bg); }
footer a { color: var(--text-2); }
/* ==================== DEMO PAGE ==================== */
.demo-shell { min-height: 100vh; background: var(--bg); }
/* Top nav */
.demo-nav {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 0.9rem 1.5rem;
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 1rem;
}
.demo-nav .brand { display: flex; align-items: center; gap: 0.7rem; font-size: 0.95rem; }
.demo-nav .brand strong { font-weight: 600; }
.demo-nav .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--text-3); }
.demo-nav .dot.on { background: var(--pos); animation: pulse 2s infinite; }
.demo-nav .dot.pend { background: var(--warn); }
.demo-nav .dot.bad { background: var(--neg); }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.55); }
50% { box-shadow: 0 0 0 6px rgba(22, 163, 74, 0); }
}
.demo-nav .status-text { color: var(--text-2); font-size: 0.88rem; }
.demo-nav .nav-links { display: flex; gap: 1rem; align-items: center; }
.demo-nav .nav-links a { color: var(--text-2); font-size: 0.88rem; font-weight: 500; }
.demo-nav .nav-links a:hover { color: var(--accent); }
/* Hero question */
.hero-q {
background: linear-gradient(180deg, #ffffff 0%, var(--bg) 100%);
border-bottom: 1px solid var(--border);
padding: 2.5rem 1.5rem;
}
.hero-q-inner { max-width: 1120px; margin: 0 auto; text-align: center; }
.q-line {
font-size: 1.6rem; font-weight: 500; color: var(--text-2);
max-width: 800px; margin: 0 auto 0.9rem;
letter-spacing: -0.01em;
}
.a-line {
font-size: 2.4rem; font-weight: 700; letter-spacing: -0.02em;
line-height: 1.15; color: var(--text); margin-bottom: 0.6rem;
min-height: 3rem; display: flex; align-items: center; justify-content: center; gap: 0.6rem;
flex-wrap: wrap;
}
@media (min-width: 700px) { .a-line { font-size: 2.8rem; } }
.a-line .verdict { font-size: 1em; }
.a-line .verdict.no { color: var(--text-2); }
.a-line .verdict.yes { color: var(--pos); }
.a-line .verdict.near { color: var(--warn); }
.a-line .detail { font-weight: 600; font-size: 0.9em; color: var(--text-2); }
.a-line .cost-chip {
font-family: var(--mono); font-weight: 700;
padding: 0.15em 0.5em; border-radius: 8px; background: var(--surface);
border: 1px solid var(--border); font-size: 0.85em;
}
.a-line .cost-chip.no { color: var(--text); }
.a-line .cost-chip.yes { color: var(--pos); border-color: rgba(22,163,74,0.3); background: var(--pos-soft); }
.a-line .cost-chip.near{ color: var(--warn); border-color: rgba(217,119,6,0.3); background: var(--warn-soft); }
.a-sub {
color: var(--text-3); font-size: 0.9rem;
max-width: 600px; margin: 0 auto;
}
.a-sub strong { color: var(--text); }
.spinner {
width: 1em; height: 1em;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.9s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
.muted { color: var(--text-3); }
/* Workbench layout */
.workbench {
max-width: 1120px; margin: 0 auto; padding: 1.5rem;
display: grid; gap: 1.25rem; grid-template-columns: 1fr;
}
@media (min-width: 960px) {
.workbench { grid-template-columns: 310px 1fr; }
}
/* Left column: event cards */
.column-head h3 {
font-size: 0.8rem; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--text-3); margin: 0 0 0.3rem; font-weight: 600;
}
.column-head p { margin: 0 0 0.8rem; color: var(--text-3); font-size: 0.82rem; }
.event-cards { display: flex; flex-direction: column; gap: 0.55rem; }
.event-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.75rem 0.9rem;
cursor: pointer; transition: all 0.1s ease;
display: flex; flex-direction: column; gap: 0.3rem;
}
.event-card:hover { border-color: var(--accent); box-shadow: var(--shadow-lg); transform: translateY(-1px); }
.event-card.active { border-color: var(--accent); background: #f0f9ff; box-shadow: var(--shadow-lg); }
.event-card .ec-title { font-weight: 500; font-size: 0.92rem; color: var(--text); }
.event-card .ec-meta {
display: flex; justify-content: space-between; align-items: center;
font-size: 0.78rem;
}
.event-card .ec-count { color: var(--text-3); }
.event-card .ec-cost {
font-family: var(--mono); font-weight: 600;
padding: 2px 8px; border-radius: 4px;
background: var(--surface-2); color: var(--text-3);
}
.event-card .ec-cost.no { background: var(--surface-2); color: var(--text-3); }
.event-card .ec-cost.near { background: var(--warn-soft); color: var(--warn); }
.event-card .ec-cost.yes { background: var(--pos-soft); color: var(--pos); font-weight: 700; }
.event-card.skeleton { cursor: default; color: var(--text-3); font-size: 0.88rem; text-align: center; padding: 1.5rem 0.9rem; }
/* Right column: focused event + calculator */
.focused-event {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 1.5rem 1.6rem;
box-shadow: var(--shadow);
}
.focused-head { margin-bottom: 1.2rem; }
.focused-title-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.focused-event h2 {
font-size: 1.35rem; font-weight: 700; margin: 0 0 0.25rem;
letter-spacing: -0.01em; color: var(--text);
}
.focused-event .subtitle { color: var(--text-3); font-size: 0.85rem; }
.why-btn {
background: var(--surface-2); border: 1px solid var(--border);
color: var(--text-2); font-size: 0.8rem; font-weight: 500;
padding: 0.3rem 0.7rem; border-radius: 999px; cursor: pointer;
transition: all 0.1s ease;
}
.why-btn:hover { background: var(--surface); border-color: var(--accent); color: var(--accent); }
/* The calculator card — the star */
.calc-card {
background: var(--surface-2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1.25rem;
margin-bottom: 1.5rem;
}
.calc-row.calc-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; gap: 1rem; }
.calc-label { font-size: 0.88rem; color: var(--text-2); font-weight: 500; }
.status {
font-size: 0.75rem; font-weight: 700; padding: 3px 10px; border-radius: 999px;
text-transform: uppercase; letter-spacing: 0.04em; flex-shrink: 0;
}
.status.arb { background: var(--pos-soft); color: var(--pos); }
.status.near { background: var(--warn-soft); color: var(--warn); }
.status.fair { background: var(--surface); color: var(--text-3); border: 1px solid var(--border); }
.calc-grid {
display: grid; grid-template-columns: 1fr; gap: 0.5rem;
margin-bottom: 1rem;
}
@media (min-width: 620px) { .calc-grid { grid-template-columns: 1fr 1fr 1fr; gap: 0.75rem; } }
.calc-cell {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.8rem 1rem;
}
.calc-cell.highlight { border-color: var(--border-2); }
.calc-cell .cell-label { font-size: 0.75rem; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; font-weight: 500; }
.calc-cell .cell-val { font-family: var(--mono); font-size: 1.6rem; font-weight: 700; color: var(--text); letter-spacing: -0.01em; margin-top: 0.15rem; }
.calc-cell .cell-val.is-fixed { color: var(--text-2); }
.calc-cell .cell-val.pos { color: var(--pos); }
.calc-cell .cell-val.neg { color: var(--neg); }
.calc-cell .cell-val.near { color: var(--warn); }
.calc-cell .cell-sub { font-size: 0.78rem; color: var(--text-3); margin-top: 0.1rem; }
.scale-row { display: flex; align-items: center; gap: 0.8rem; margin-bottom: 0.5rem; flex-wrap: wrap; }
.scale-lbl { font-size: 0.85rem; color: var(--text-2); }
.scale-buttons { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.scale-buttons button {
background: var(--surface); border: 1px solid var(--border);
color: var(--text-2); font-size: 0.82rem; font-weight: 500;
padding: 0.35rem 0.75rem; border-radius: 6px; cursor: pointer;
transition: all 0.1s ease;
}
.scale-buttons button:hover { border-color: var(--border-2); color: var(--text); }
.scale-buttons button.active { background: var(--text); border-color: var(--text); color: white; }
.scale-out {
font-size: 0.86rem; color: var(--text-2); padding: 0.6rem 0.9rem;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
margin-bottom: 0.9rem; min-height: 1.5em;
line-height: 1.55;
}
.scale-out strong { color: var(--text); font-family: var(--mono); }
.scale-out .gain { color: var(--pos); font-weight: 600; font-family: var(--mono); }
.scale-out .loss { color: var(--neg); font-weight: 600; font-family: var(--mono); }
/* Threshold bar */
.threshold-bar {
position: relative; height: 10px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 999px; overflow: hidden; margin-top: 0.3rem; margin-bottom: 0.4rem;
}
.threshold-bar .fill {
position: absolute; left: 0; top: 0; bottom: 0;
background: var(--accent); transition: width 0.35s ease, background 0.35s ease;
}
.threshold-bar .fill.arb { background: var(--pos); }
.threshold-bar .fill.near { background: var(--warn); }
.threshold-bar .marker-dollar {
position: absolute; top: -3px; bottom: -3px; width: 2px;
background: var(--text); left: 50%;
}
.threshold-bar-labels {
display: flex; justify-content: space-between; font-size: 0.72rem;
color: var(--text-3); position: relative;
}
.threshold-bar-labels .center {
position: absolute; left: 50%; transform: translateX(-50%); font-weight: 600; color: var(--text-2);
}
/* The outcomes list */
.section-head { margin: 0 0 0.8rem; }
.section-head h3 { font-size: 1rem; font-weight: 600; margin: 0 0 0.25rem; color: var(--text); }
.section-head p { color: var(--text-3); font-size: 0.86rem; margin: 0; max-width: 640px; }
.outcome-list { display: flex; flex-direction: column; gap: 0.45rem; }
.outcome-row {
display: grid; grid-template-columns: 1fr auto; gap: 0.75rem;
align-items: center; padding: 0.8rem 1rem;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
}
.outcome-row.highlighted { background: var(--pos-soft); border-color: rgba(22,163,74,0.35); }
.outcome-row.empty, .outcome-row.skeleton { opacity: 0.7; color: var(--text-3); }
.outcome-row.skeleton { justify-content: center; text-align: center; }
.outcome-row .left { min-width: 0; }
.outcome-row .outcome-name {
font-size: 0.94rem; font-weight: 500; color: var(--text);
margin-bottom: 0.3rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.outcome-row .prob-bar {
height: 5px; background: var(--surface-2); border-radius: 999px; overflow: hidden; width: 100%;
}
.outcome-row .prob-bar .prob-fill {
height: 100%; background: var(--accent); transition: width 0.35s ease;
}
.outcome-row .right { display: flex; flex-direction: column; align-items: flex-end; min-width: 120px; }
.outcome-row .pct {
font-family: var(--mono); font-size: 1.2rem; font-weight: 700; color: var(--text);
letter-spacing: -0.01em;
}
.outcome-row .price-size { font-family: var(--mono); font-size: 0.76rem; color: var(--text-3); margin-top: 2px; }
/* FAQ */
.faq {
background: var(--surface); border-top: 1px solid var(--border);
padding: 3rem 1.5rem; margin-top: 1.5rem;
}
.faq-inner { max-width: 780px; margin: 0 auto; }
.faq-inner h2 { font-size: 1.3rem; font-weight: 700; margin: 0 0 1.2rem; letter-spacing: -0.01em; }
.faq-item {
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.25rem 1.1rem;
margin-bottom: 0.6rem; transition: border-color 0.12s ease;
}
.faq-item[open] { border-color: var(--accent); background: var(--surface); }
.faq-item summary {
cursor: pointer; padding: 0.85rem 0; font-weight: 600; font-size: 0.98rem;
list-style: none; position: relative; padding-left: 1.6rem;
color: var(--text);
}
.faq-item summary::-webkit-details-marker { display: none; }
.faq-item summary::before {
content: "+"; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
font-size: 1.3rem; color: var(--text-3); font-weight: 400; width: 1.2rem; text-align: center;
}
.faq-item[open] summary::before { content: ""; color: var(--accent); }
.faq-item p {
color: var(--text-2); font-size: 0.93rem; line-height: 1.6;
margin: 0 0 0.85rem; padding-bottom: 0.2rem;
}
.faq-item p:last-child { margin-bottom: 0.6rem; }
.faq-item p strong { color: var(--text); }
/* Live stats footer */
.live-stats {
background: var(--surface); border-top: 1px solid var(--border);
padding: 1.5rem;
}
.live-inner {
max-width: 1120px; margin: 0 auto;
display: grid; gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.stat { text-align: center; }
.stat-num { font-family: var(--mono); font-size: 1.5rem; font-weight: 700; color: var(--text); }
.stat-lbl { font-size: 0.75rem; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; margin-top: 0.15rem; }
.live-caption {
max-width: 600px; margin: 1rem auto 0; text-align: center;
font-size: 0.82rem; color: var(--text-3);
}
.site-footer {
text-align: center; padding: 1.5rem; font-size: 0.85rem;
color: var(--text-3); background: var(--bg);
}
.site-footer a { color: var(--text-2); }
/* Modal */
.modal { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.modal[hidden] { display: none; }
.modal-backdrop { position: absolute; inset: 0; background: rgba(15, 23, 42, 0.45); backdrop-filter: blur(2px); cursor: pointer; }
.modal-card {
position: relative; background: var(--surface); border-radius: var(--radius-lg);
max-width: 540px; width: 100%; padding: 1.8rem 2rem 1.6rem;
box-shadow: 0 20px 50px rgba(15,23,42,0.25);
max-height: 80vh; overflow-y: auto;
}
.modal-card h3 { margin: 0 0 0.5rem; font-size: 1.2rem; }
.modal-card p { color: var(--text-2); font-size: 0.95rem; line-height: 1.6; }
.modal-x {
position: absolute; top: 0.8rem; right: 0.8rem;
background: transparent; border: 0; font-size: 1.6rem; color: var(--text-3);
cursor: pointer; line-height: 1; padding: 0.2rem 0.5rem; border-radius: 6px;
}
.modal-x:hover { background: var(--surface-2); color: var(--text); }
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+277
View File
@@ -0,0 +1,277 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Live Polymarket Arbitrage Scanner</title>
<meta name="description" content="Can you make free money on Polymarket right now? This page watches Polymarket's live order book and shows you the answer in real time." />
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<link rel="stylesheet" href="assets/style.css?v=6" />
</head>
<body>
<div class="demo-shell">
<nav class="demo-nav">
<div class="brand">
<span id="conn-dot" class="dot pend"></span>
<strong>Polymarket Arbitrage Scanner</strong>
<span id="conn-label" class="status-text">connecting…</span>
</div>
<div class="nav-links">
<a href="index.html">About this project</a>
<a href="https://polymarket.com" target="_blank" rel="noopener">What is Polymarket?</a>
<a href="https://github.com/matthewnyc2/arbitrage" target="_blank" rel="noopener">Source</a>
</div>
</nav>
<!-- Giant hero: one question, one live answer -->
<section class="hero-q">
<div class="hero-q-inner">
<div class="q-line">Can you make free money on Polymarket right now?</div>
<div class="a-line" id="a-line">
<span class="spinner"></span><span class="muted">checking live prices…</span>
</div>
<div class="a-sub" id="a-sub">
Scanning <span id="watch-count">6</span> live events from Polymarket for an arbitrage window.
</div>
</div>
</section>
<!-- The main workbench: one event at a time, clickable -->
<div class="workbench">
<aside class="event-column">
<div class="column-head">
<h3>Live events from Polymarket</h3>
<p>Click any event to see the math for it.</p>
</div>
<div id="event-list" class="event-cards">
<div class="event-card skeleton">loading…</div>
</div>
</aside>
<main class="main-column">
<section class="focused-event">
<div class="focused-head">
<div class="focused-title-row">
<h2 id="event-title"></h2>
<button class="why-btn" type="button" id="why-this-event">What is this event?</button>
</div>
<div class="subtitle" id="event-subtitle">Pick an event on the left to see the arbitrage math.</div>
</div>
<!-- The big interactive calculator block -->
<div class="calc-card">
<div class="calc-row calc-head">
<div class="calc-label">If you bought one share of every possible answer right now</div>
<span class="status fair" id="basket-status">waiting</span>
</div>
<div class="calc-grid">
<div class="calc-cell">
<div class="cell-label">You would pay</div>
<div class="cell-val" id="basket-cost"></div>
<div class="cell-sub" id="basket-cost-sub">for one full set</div>
</div>
<div class="calc-cell">
<div class="cell-label">You would get back</div>
<div class="cell-val is-fixed">$1.00</div>
<div class="cell-sub">guaranteed — exactly one answer wins</div>
</div>
<div class="calc-cell highlight">
<div class="cell-label">Your profit</div>
<div class="cell-val" id="basket-pnl"></div>
<div class="cell-sub" id="basket-pnl-sub">per one-set bet</div>
</div>
</div>
<div class="scale-row">
<span class="scale-lbl">What if you bought more?</span>
<div class="scale-buttons" id="scale-buttons">
<button type="button" data-qty="1" class="active">1 set</button>
<button type="button" data-qty="10">10 sets</button>
<button type="button" data-qty="100">100 sets</button>
<button type="button" data-qty="1000">1,000 sets</button>
</div>
</div>
<div class="scale-out" id="scale-out"></div>
<div class="threshold-bar">
<div class="fill" id="basket-fill" style="width: 0%;"></div>
<div class="marker-dollar" title="$1.00"></div>
</div>
<div class="threshold-bar-labels">
<span class="left">$0.80 — you'd pocket 20¢ per set</span>
<span class="center">$1.00 — break-even</span>
<span class="right">$1.20 — overpriced</span>
</div>
</div>
<div class="section-head">
<h3>The possible answers and what the market thinks of each</h3>
<p>
Each row is one possible answer. The percentage is the market's
current estimate of that answer being correct — because a $0.72
share pays $1 when it wins, it must be priced at roughly a 72%
probability.
</p>
</div>
<div class="outcome-list" id="outcome-list">
<div class="outcome-row skeleton">loading…</div>
</div>
</section>
</main>
</div>
<!-- FAQ - collapsible plain-English Q&A -->
<section class="faq">
<div class="faq-inner">
<h2>Questions you probably have</h2>
<details class="faq-item" open>
<summary>What is Polymarket?</summary>
<p>
Polymarket is an online betting site for real-world questions —
elections, sports, Fed meetings, crypto prices, award shows.
Each question has a list of possible answers, and each answer is
a share you can buy. When the real-world event resolves, shares
of the winning answer pay exactly <strong>$1</strong>, and every
other share pays <strong>$0</strong>. The price of a share is
basically the market's guess at how likely that answer is.
</p>
</details>
<details class="faq-item">
<summary>Why does "buying one share of every answer" always pay $1?</summary>
<p>
Because exactly one answer can win. If the event has four possible
outcomes (say: "25 bps up", "no change", "25 bps down", "50+ bps
down") and you own one share of each, then <em>whatever</em>
happens, you hold the winning share — and that share pays $1.
The other three pay $0. You paid for all four, but only the
winner matters.
</p>
</details>
<details class="faq-item">
<summary>So where's the free money?</summary>
<p>
If the <em>total</em> cost of buying one of every answer drops
below $1, you're guaranteed to make the difference. Example: if
the four answers cost 60¢, 30¢, 7¢, and 2¢ — total 99¢ — you
pay 99¢ now and receive exactly $1 later, no matter which one
wins. That's a risk-free 1¢ profit on every $1 you bet.
</p>
</details>
<details class="faq-item">
<summary>Why am I seeing "no free money" every time I check?</summary>
<p>
Because the math is public and professional trading bots watch it
too. The moment the total dips below $1, they instantly buy every
outcome and push the price back up. Arbitrage on big, popular
Polymarket events is usually closed within milliseconds. A browser
page running on your laptop is not going to beat them.
</p>
<p>
This demo exists to show <em>how</em> the math works and
<em>how often</em> the opportunities actually appear. Watching it
produce nothing for 30 minutes is more informative than a fake
demo that flashes profits every second.
</p>
</details>
<details class="faq-item">
<summary>Is this actually live?</summary>
<p>
Yes. Your browser opens a WebSocket to Polymarket's public CLOB
(the same one their website uses) and receives real price updates
every few milliseconds. The number of price updates received is
in the footer below — it ticks up in real time. The event list
is refreshed hourly by a GitHub Action so you're always looking
at currently-open markets.
</p>
</details>
<details class="faq-item">
<summary>Does this page actually trade?</summary>
<p>
<strong>No.</strong> This page is read-only. It does not hold money,
sign transactions, or submit orders. It's a live view of the math.
The full Python bot in the GitHub repo can submit real orders, but
only with explicit operator configuration and hard risk caps —
and even then it defaults to a dry-run mode that logs orders
instead of broadcasting them.
</p>
</details>
<details class="faq-item">
<summary>Why would I hire the person who built this?</summary>
<p>
Because the same skills — reading real-time market data, writing
low-latency async pipelines, building production-grade risk
systems, and shipping interactive demos that explain themselves —
solve a lot of other, more lucrative problems than retail
arbitrage. If you have a problem that looks like "connect to a
streaming API, do math on every event, take action under hard
constraints," that's this whole project in one sentence.
</p>
</details>
</div>
</section>
<!-- Footer stats: show this is truly live -->
<footer class="live-stats">
<div class="live-inner">
<div class="stat">
<div class="stat-num" id="stat-events"></div>
<div class="stat-lbl">events watched</div>
</div>
<div class="stat">
<div class="stat-num" id="stat-tokens"></div>
<div class="stat-lbl">answers tracked</div>
</div>
<div class="stat">
<div class="stat-num" id="stat-books">0</div>
<div class="stat-lbl">order books active</div>
</div>
<div class="stat">
<div class="stat-num" id="stat-msgs">0</div>
<div class="stat-lbl">price updates received</div>
</div>
<div class="stat">
<div class="stat-num" id="stat-uptime">00:00</div>
<div class="stat-lbl">running for</div>
</div>
</div>
<p class="live-caption">
Every number above comes from Polymarket's servers. Nothing is mocked.
Close this tab and it all stops.
</p>
</footer>
<footer class="site-footer">
<p>
<a href="index.html">About this project</a> ·
<a href="https://github.com/matthewnyc2/arbitrage" target="_blank" rel="noopener">Source code</a>
</p>
</footer>
</div>
<!-- Modal for "what is this event" -->
<div class="modal" id="modal" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal-card">
<button type="button" class="modal-x" data-close aria-label="close">×</button>
<div id="modal-content"></div>
</div>
</div>
<script src="assets/demo.js?v=7"></script>
</body>
</html>
+227
View File
@@ -0,0 +1,227 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Polymarket Arbitrage Scanner — portfolio</title>
<meta name="description" content="Production-grade Python asyncio scanner for NegRisk multi-outcome arbitrage on Polymarket. Live book state, depth-clipped edge math, paper-trading mode." />
<link rel="stylesheet" href="assets/style.css" />
</head>
<body>
<header class="hero">
<div class="hero-inner">
<h1>Polymarket Arbitrage Scanner</h1>
<p class="tagline">
Production-grade Python asyncio system that detects NegRisk multi-outcome
arbitrage on Polymarket in real time, simulates fills against the live
order book with a realistic latency penalty, and can sign and submit live
orders behind hard risk caps.
</p>
<div class="badges">
<span class="badge pos">60 tests passing</span>
<span class="badge pos">94% core-math coverage</span>
<span class="badge">python 3.12</span>
<span class="badge">asyncio</span>
<span class="badge">fastapi</span>
<span class="badge">websockets</span>
<span class="badge">web3.py</span>
<span class="badge">sqlite (wal)</span>
<span class="badge">docker</span>
<span class="badge">MIT</span>
</div>
<div class="btn-row">
<a class="btn primary" href="demo.html">Live demo →</a>
<a class="btn" href="https://github.com/matthewnyc2/arbitrage" target="_blank" rel="noopener">GitHub</a>
<a class="btn" href="https://github.com/matthewnyc2/arbitrage#quickstart" target="_blank" rel="noopener">Quickstart</a>
</div>
</div>
</header>
<section>
<div class="container">
<h2><span class="rule"></span>The arbitrage, in one paragraph</h2>
<p>
Polymarket hosts <em>categorical</em> events — "Who wins the 2028 US Election?" —
where every outcome trades as its own YES token on a public CLOB. Because
exactly one outcome must win, the fair prices across all outcomes are bound
to sum to exactly <strong>$1</strong>. When the sum of best-asks drops below
$1, buying one share of every outcome locks in a guaranteed $1 payout on
resolution. The challenge isn't detecting the mispricing — it's racing
faster bots, walking order-book depth honestly so edge isn't a fantasy at
top-of-book, paying realistic Polygon gas, and surviving UMA resolution
disputes that can void a "risk-free" basket. This project builds the full
pipeline for that strategy, end to end, with paper mode as the primary
validation tool.
</p>
<div class="metrics-grid" style="margin-top: 2rem;">
<div class="metric"><span class="val">~2k</span><span class="label">lines of python</span></div>
<div class="metric"><span class="val">60</span><span class="label">unit tests</span></div>
<div class="metric"><span class="val">94%</span><span class="label">coverage (core)</span></div>
<div class="metric"><span class="val">~5s</span><span class="label">test suite time</span></div>
<div class="metric"><span class="val">2</span><span class="label">modes (paper / live)</span></div>
<div class="metric"><span class="val">7</span><span class="label">risk caps enforced</span></div>
</div>
</div>
</section>
<section>
<div class="container">
<h2><span class="rule"></span>What's in the box</h2>
<div class="grid-2">
<div class="card">
<h3>Gamma REST discovery</h3>
<p>Paginates Polymarket's <code>/events</code> endpoint, filters to active
NegRisk categoricals, normalizes into pydantic models, upserts idempotently
into SQLite, and marks dropped events inactive.</p>
</div>
<div class="card">
<h3>WebSocket L2 maintainer</h3>
<p>Subscribes to the CLOB <code>market</code> channel. Parses <code>book</code>
snapshots and <code>price_change</code> deltas against a per-token sorted
price ladder. Shards across sockets, keeps connections alive with PING/PONG,
reconnects with exponential backoff.</p>
</div>
<div class="card">
<h3>Opportunity engine</h3>
<p>On every book tick, walks depth across all outcomes of the affected
event, computes the basket size that maximizes net expected profit after
fees and amortised gas, emits a typed <code>Opportunity</code> record
only when the edge clears a configured threshold.</p>
</div>
<div class="card">
<h3>Paper executor</h3>
<p>Simulates IOC fills at <em>detection time + latency penalty</em> against
the live book, so levels that vanished during the simulated latency
window model "being beaten by a faster bot." Writes baskets + per-leg fills
to SQLite; closes out PnL when the underlying event resolves.</p>
</div>
<div class="card">
<h3>Live executor (gated)</h3>
<p>Signs EIP-712 orders through <code>py-clob-client</code>, submits FAK
across all legs in parallel, attempts to unwind any partial fill, calls
<code>NegRiskAdapter.redeemPositions</code> on the winning leg. Default
<code>dry_run=True</code>; real broadcast requires an explicit operator flip.</p>
</div>
<div class="card">
<h3>Risk gate</h3>
<p>Hard caps before any order touches the network: max basket USD, max
open baskets (global and per-event), daily loss stop, kill-switch file,
proximity-to-resolution skip. Every deny is logged with its reason.</p>
</div>
<div class="card">
<h3>FastAPI + HTMX dashboard</h3>
<p>Single-page viewer that polls SQLite every few seconds for live
opportunities, open and historical baskets, realized paper PnL, and mode
indicator. One-click kill-switch toggle. No SPA build step.</p>
</div>
<div class="card">
<h3>Docker + CLI</h3>
<p>Single-binary CLI (<code>arb init | discover | scan | web | resolve</code>)
plus Dockerfile + compose file for a one-command local run. Runs on a
$5/month GCP VM or on a laptop.</p>
</div>
</div>
</div>
</section>
<section>
<div class="container">
<h2><span class="rule"></span>Architecture</h2>
<pre class="arch-diagram">
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</pre>
</div>
</section>
<section>
<div class="container">
<h2><span class="rule"></span>Run it yourself</h2>
<div class="grid-2">
<div class="card">
<h3>Local (Python 3.12+)</h3>
<pre><code>git clone https://github.com/matthewnyc2/arbitrage
cd arbitrage
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env
arb init
arb discover
arb scan &amp;
arb web
# http://127.0.0.1:8000</code></pre>
</div>
<div class="card">
<h3>Docker</h3>
<pre><code>docker compose up
# http://127.0.0.1:8000</code></pre>
<p style="margin-top: 1rem;">Stays in paper mode — no keys, no capital at
risk. Kill with <span class="kbd">Ctrl+C</span> or by touching
<code>./KILL</code>.</p>
</div>
</div>
</div>
</section>
<section>
<div class="container">
<h2><span class="rule"></span>What this is (and isn't)</h2>
<p>
This is a working engineering project — a reference for how to structure a
latency-sensitive async trading bot with disciplined risk gates, honest
simulated fills, and a testable core. It is <strong>not</strong> a
get-rich tool. The retail edge in public prediction-market arbitrage has
mostly been competed away by professional market makers with colocation,
custom hardware, and seven-figure working capital. Paper mode here exists
specifically to answer the question <em>is there any edge left for a solo
Python bot</em>, before a single dollar is risked.
</p>
<p>
The architecture transfers cleanly to any order-book venue (Kalshi,
Manifold, CEX spot markets) — swap the REST + WS adapters and the
engine keeps working.
</p>
</div>
</section>
<footer>
<p>
<a href="https://github.com/matthewnyc2/arbitrage" target="_blank" rel="noopener">GitHub</a> ·
<a href="demo.html">Live demo</a> ·
MIT licensed
</p>
<p class="small">Built with Python, asyncio, FastAPI, HTMX, web3.py, and a lot of Polymarket docs reading.</p>
</footer>
</body>
</html>
+211
View File
@@ -0,0 +1,211 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Polymarket Strategy Lab — honest backtests</title>
<meta name="description" content="See how five different arbitrage and betting strategies actually performed on 135 real resolved Polymarket events. No cherry-picked examples. No lies." />
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<link rel="stylesheet" href="assets/style.css?v=7" />
<link rel="stylesheet" href="assets/lab.css?v=3" />
</head>
<body>
<div class="lab-shell">
<nav class="demo-nav">
<div class="brand">
<span class="dot on"></span>
<strong>Polymarket Strategy Lab</strong>
<span class="status-text"><span id="event-count-inline"></span> real resolved events · backtested in your browser</span>
</div>
<div class="nav-links">
<a href="index.html">About the project</a>
<a href="demo.html">Live scanner</a>
<a href="https://github.com/matthewnyc2/arbitrage" target="_blank" rel="noopener">Source</a>
</div>
</nav>
<div class="tabs">
<div class="tabs-inner">
<button type="button" class="tab active" data-tab="results" id="tab-results">
Results
</button>
<button type="button" class="tab" data-tab="strategies" id="tab-strategies">
Strategies <span class="tab-count">5</span>
</button>
</div>
</div>
<!-- =================== RESULTS TAB =================== -->
<main class="panel active" id="panel-results">
<section class="lab-hero">
<div class="lab-hero-inner">
<div class="hero-strategy-row">
<div class="strategy-badge" id="active-strategy-label">Strategy</div>
<button type="button" class="switch-btn" id="switch-btn">
Change strategy
<span class="arrow"></span>
</button>
</div>
<h1 id="active-strategy-name"></h1>
<p id="active-strategy-desc" class="lead"></p>
<div class="bankroll-row">
<span class="bankroll-lbl">Bankroll per opportunity</span>
<div class="bankroll-choices" id="bankroll-choices">
<button type="button" data-bankroll="100">$100</button>
<button type="button" data-bankroll="1000" class="active">$1,000</button>
<button type="button" data-bankroll="10000">$10,000</button>
<button type="button" data-bankroll="100000">$100,000</button>
</div>
</div>
<div class="bankroll-row">
<span class="bankroll-lbl">Trade at prices from</span>
<div class="bankroll-choices" id="window-choices">
<button type="button" data-window="close">at close</button>
<button type="button" data-window="1h">1h before</button>
<button type="button" data-window="6h">6h before</button>
<button type="button" data-window="24h" class="active">24h before</button>
<button type="button" data-window="3d">3 days before</button>
<button type="button" data-window="7d">7 days before</button>
</div>
<span class="bankroll-note" id="window-note">
These are <strong>real historical Polymarket prices</strong>, pulled from
their public CLOB price-history endpoint. For each event, we look up the
actual price of every outcome at the chosen moment before the market closed.
</span>
</div>
</div>
</section>
<section class="verdict-section">
<div class="verdict-inner">
<div class="verdict-card" id="verdict-card">
<div class="verdict-icon" id="verdict-icon"></div>
<div class="verdict-body">
<div class="verdict-label" id="verdict-label">calculating…</div>
<div class="verdict-detail" id="verdict-detail">Running the strategy against every resolved event</div>
</div>
</div>
<div class="verdict-stats">
<div class="vstat">
<div class="vstat-val" id="vstat-pnl"></div>
<div class="vstat-lbl" id="vstat-pnl-lbl">Total profit across every trade</div>
</div>
<div class="vstat">
<div class="vstat-val" id="vstat-roi"></div>
<div class="vstat-lbl">Return on bankroll per trade</div>
</div>
<div class="vstat">
<div class="vstat-val" id="vstat-trades"></div>
<div class="vstat-lbl">Trades taken (out of 93 events)</div>
</div>
<div class="vstat">
<div class="vstat-val" id="vstat-winrate"></div>
<div class="vstat-lbl">Fraction of trades that won</div>
</div>
<div class="vstat">
<div class="vstat-val" id="vstat-annual"></div>
<div class="vstat-lbl" id="vstat-annual-lbl">Projected annual profit</div>
</div>
</div>
<p class="verdict-explainer" id="verdict-explainer"></p>
</div>
</section>
<section class="trades-section">
<div class="trades-inner">
<div class="section-header">
<h2>Every trade, one row each</h2>
<p>
Each row below is one resolved Polymarket event. The strategy either
placed a trade or skipped it. When it traded, you can see exactly
what it paid, what it got back, and whether it won money.
</p>
<div class="trade-filter">
<button type="button" class="filter-btn active" data-filter="all">All <span id="cnt-all">0</span></button>
<button type="button" class="filter-btn" data-filter="trades">Took trade <span id="cnt-trades">0</span></button>
<button type="button" class="filter-btn" data-filter="wins">Wins <span id="cnt-wins">0</span></button>
<button type="button" class="filter-btn" data-filter="losses">Losses <span id="cnt-losses">0</span></button>
<button type="button" class="filter-btn" data-filter="skipped">Skipped <span id="cnt-skipped">0</span></button>
</div>
</div>
<div id="trade-list" class="trade-list"></div>
</div>
</section>
<section class="data-section">
<div class="data-inner">
<h2>What you're looking at, in plain English</h2>
<p>
These events are real Polymarket markets that have already ended
in the last few weeks. For each one, we know who won and we have
the actual price of every outcome at every moment before the
market closed. The page runs a trading strategy on every event
and adds up how much money you would have made or lost.
</p>
<p>
The <strong>"trade at prices from"</strong> selector above is the
important knob. Prices right before a market closes tend to be
correct (because everyone already knows the answer). Prices a
day or more earlier are often noticeably off — and that's where
real arbitrage lives. Try clicking the different time windows
and watch the numbers change.
</p>
<p>
<strong>Every price on this page is a real price Polymarket
recorded.</strong> Pulled from their public CLOB price-history
API. No estimates, no math tricks. You can verify every trade
by looking up the event on polymarket.com.
</p>
</div>
</section>
</main>
<!-- =================== STRATEGIES TAB =================== -->
<main class="panel" id="panel-strategies">
<section class="strategies-hero">
<div class="strategies-hero-inner">
<h1>Pick a strategy to see how it actually performed</h1>
<p>
Each of these strategies has a clear rule. Each was run against the
same <span id="event-count-strat"></span> real resolved Polymarket
events. Results are plain: did it make money or lose money, and by
how much.
</p>
</div>
</section>
<section class="strategy-grid-section">
<div class="strategy-grid" id="strategy-grid"></div>
</section>
</main>
</div>
<!-- strategy detail modal -->
<div class="modal" id="strategy-modal" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal-card modal-wide">
<button type="button" class="modal-x" data-close aria-label="close">×</button>
<div id="strategy-modal-content"></div>
</div>
</div>
<footer class="site-footer">
<p>
Backtested on real, resolved Polymarket events —
<a href="https://github.com/matthewnyc2/arbitrage" target="_blank" rel="noopener">source on GitHub</a> ·
<a href="index.html">About this portfolio</a>
</p>
</footer>
<script src="assets/lab.js?v=8"></script>
</body>
</html>
+562
View File
@@ -0,0 +1,562 @@
# Polymarket 套利机器人 — 中文使用手册
> 适用版本:v0.1.0 · Python 3.12+ · Windows / Linux / macOS
>
> 本文从环境准备到日常运维,按真实使用顺序逐步讲解。
---
## 目录
1. [项目简介](#1-项目简介)
2. [系统要求](#2-系统要求)
3. [快速安装](#3-快速安装)
4. [首次配置](#4-首次配置)
5. [首次运行](#5-首次运行)
6. [日常使用(Paper 模式)](#6-日常使用paper-模式)
7. [Web 仪表板](#7-web-仪表板)
8. [切换到 Live 模式](#8-切换到-live-模式)
9. [监控与运维](#9-监控与运维)
10. [故障排查](#10-故障排查)
11. [安全建议](#11-安全建议)
12. [附录:环境变量参考](#附录环境变量参考)
---
## 1. 项目简介
**Polymarket NegRisk 多结果套利机器人**。Polymarket 的某些分类事件(例如"2028 大选谁赢")每个结果独立挂牌交易。当**所有结果的卖一价加总 < $1** 时,买入一整套结果,结算时一定收回 $1 → **无风险套利**
本机器人扫描所有活跃 negRisk 事件,实时计算套利空间,提供两种模式:
| 模式 | 资金风险 | 用途 |
|------|---------|------|
| **Paper** | $0 | 模拟成交,验证策略,观察 PnL |
| **Live** | 实盘资金 | 真实下单,需提供钱包私钥 |
---
## 2. 系统要求
| 项目 | 要求 |
|------|------|
| **Python** | 3.12 或更高 |
| **操作系统** | Windows 10+ / Linux / macOS |
| **内存** | ≥ 512 MB |
| **磁盘** | ≥ 1 GB(含 SQLite 与日志) |
| **网络** | **必须**能访问 `polymarket.com`(国内需配置代理) |
| **钱包**(仅 Live | Polygon 上的专用 EOA,持有 USDC.e |
**国内用户注意**:Polymarket 服务器在国内无法直连,必须配置 HTTP 代理(详见 [§4.2 网络代理](#42-网络代理))。
---
## 3. 快速安装
### 3.1 克隆项目
```bash
git clone https://github.com/matthewnyc2/arbitrage
cd arbitrage
```
### 3.2 创建虚拟环境
**Windows (PowerShell)**
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
```
**Linux / macOS**
```bash
python3 -m venv .venv
source .venv/bin/activate
```
### 3.3 安装依赖
```bash
pip install -e ".[dev]"
```
> **⚠️ 重要**:默认会装 `websockets>=13.0`pip 可能会拉到 **15.x** 版本,但该版本与项目不兼容(参见 [§10.3 websockets 版本问题](#103-websockets-版本问题))。**显式锁版本**
> ```bash
> pip install "websockets==14.2"
> ```
### 3.4 验证安装
```bash
arb --help
```
应显示:`init / discover / scan / web / resolve` 五个子命令。
---
## 4. 首次配置
### 4.1 复制环境变量模板
```bash
cp .env.example .env
```
`.env` 关键字段(Paper 模式默认值即可运行):
```ini
ARB_MODE=paper # paper = 模拟盘;live = 实盘
ARB_CLOB_HOST=https://clob.polymarket.com
ARB_GAMMA_HOST=https://gamma-api.polymarket.com
ARB_PROXY= # 国内用户填代理,见下文
```
### 4.2 网络代理
**国内 / 防火墙环境下必须配置**,否则 `arb discover` 会报 `httpx.ConnectTimeout`
**PowerShell 临时设置**(仅当前会话):
```powershell
$env:HTTPS_PROXY="http://127.0.0.1:7890" # 改成你的代理地址
$env:HTTP_PROXY="http://127.0.0.1:7890"
```
**永久设置**(写入 `.env`):
```ini
ARB_PROXY=http://127.0.0.1:7890
```
> **常见代理端口**Clash 默认 7890V2Ray 默认 10809SSR 默认 1080。
>
> 配置完成后 httpxREST)和 websocketsWS)都会通过代理连接。
### 4.3 验证代理可用
```bash
arb discover
```
预期输出:
```
seen=2000 negRisk=928 upserted=894 malformed=34
```
如果仍超时,回到 [§10.1 网络连通性](#101-网络连通性)。
---
## 5. 首次运行
### 5.1 初始化数据库
```bash
arb init
```
创建 SQLite 表结构(含 events / outcomes / opportunities / baskets / fills / live_orders / resolutions / denylist / daily_pnl)。
### 5.2 发现事件
```bash
arb discover
```
从 Polymarket Gamma API 拉取所有活跃 negRisk 事件。需要约 10–20 秒。
**持续发现**(生产部署推荐,每 180 秒刷新):
```bash
arb discover --loop --interval 180
```
### 5.3 启动扫描
```bash
arb scan
```
开始监听 CLOB WebSocket,实时检测套利机会,写入 paper baskets。
预期日志:
```
scan loop started (885 events hydrated)
ws subscribed to N tokens
```
### 5.4 启动 Web 仪表板(另一终端)
```bash
arb web
```
打开浏览器访问 <http://127.0.0.1:8000>。
---
## 6. 日常使用(Paper 模式)
### 6.1 标准三进程部署
| 终端 | 命令 | 作用 |
|------|------|------|
| 终端 1 | `arb discover --loop --interval 180` | 每 3 分钟刷新事件列表 |
| 终端 2 | `arb scan` | 实时扫描 + 模拟成交 |
| 终端 3 | `arb web` | Web 仪表板 |
**Linux/macOS 后台运行**
```bash
nohup arb discover --loop --interval 180 > logs/discover.log 2>&1 &
nohup arb scan > logs/scan.log 2>&1 &
```
### 6.2 CLI 命令速查
| 命令 | 功能 |
|------|------|
| `arb init` | 创建 SQLite schema |
| `arb discover` | 单次拉取事件 |
| `arb discover --loop` | 持续拉取 |
| `arb scan` | 启动扫描 |
| `arb web` | 启动仪表板(默认 127.0.0.1:8000 |
| `arb resolve <event_id> --winner <token_id>` | 手动标记事件结算结果 |
| `arb resolve <event_id>` | 标记为 invalid |
### 6.3 停止服务
**正常停止**:在运行终端按 `Ctrl+C`
**强制清理残留进程**(Windows):
```powershell
Get-Process python | Where-Object { $_.StartTime -gt (Get-Date).AddHours(-1) } | Stop-Process -Force
```
---
## 7. Web 仪表板
仪表板每 2–3 秒自动刷新(HTMX 轮询),无需手动操作。
### 7.1 主要面板
| 面板 | 显示内容 |
|------|---------|
| **Paper PnL** | 已实现盈亏、各状态组合数、Kill Switch 按钮 |
| **Baskets** | 最近 25 个组合:ID、事件、份数、成本、状态、PnL |
| **Recent Opportunities** | 最近 25 个检测到的机会:Σ asks、净边际、最大组合、预期利润 |
### 7.2 关键指标解读
**Σ asks < $1** → 存在套利空间。典型值:
- `0.98` → 净边际 ~2%(扣费前)
- `0.95` → 净边际 ~5%(扣费前)
- `< 0.90` → 非常罕见的深度套利
**net edge bps**:扣除手续费 + 分摊 gas 后的净边际。低于 50 bps 会被 `ARB_MIN_NET_EDGE_BPS` 过滤。
**status**
| 状态 | 含义 |
|------|------|
| `pending_resolution` | 等待事件结算 |
| `redeemed` | 已结算,PnL 入账 |
| `failed` | 部分腿未成交(paper 模拟时深度消失) |
| `invalid` | 事件被标记为无效 |
### 7.3 Kill Switch
仪表板右下角红色 **kill** 按钮,点击后立即创建 `./KILL` 文件,执行器会拒绝所有新单。再次点击 **unkill** 删除文件即可恢复。
也可在终端手动:
```bash
# Windows
New-Item -Path .\KILL -ItemType File
# Linux/macOS
touch ./KILL
```
---
## 8. 切换到 Live 模式
### 8.1 准备工作
1. **专用钱包**:在 Polygon 上创建一个新的 EOA,**不要复用个人钱包**
2. **充值**:向钱包转入 USDC.e(建议 ≥ $200,含 gas
3. **批准授权**:按 `docs/api/order-signing.md` §8 的脚本,对 CTF Exchange + NegRisk Exchange + NegRisk Adapter 三个合约授权
### 8.2 派生 L2 API 凭证
`docs/api/order-signing.md` §2 的 `bootstrap_clob_creds.py` 脚本执行一次,生成:
- `CLOB_API_KEY`
- `CLOB_SECRET`
- `CLOB_PASSPHRASE`
**这三个凭证无法恢复,丢失需重新派生。**
### 8.3 修改 `.env`
```ini
ARB_MODE=live
ARB_PRIVATE_KEY=0x... # 钱包私钥(0x 前缀)
ARB_FUNDER_ADDRESS=0x... # 资金地址(EOA 模式 = 私钥对应地址)
ARB_SIGNATURE_TYPE=0 # 0=EOA, 1=Polymarket proxy, 2=Gnosis Safe
ARB_API_KEY=...
ARB_API_SECRET=...
ARB_API_PASSPHRASE=...
# 风险上限(强烈建议保持默认值)
ARB_MAX_BASKET_USD=50
ARB_MAX_OPEN_BASKETS=3
ARB_DAILY_LOSS_STOP_USD=100
```
### 8.4 首次实盘(强烈建议 dry_run 验证)
**第 1 步**:先用 dry_run 验证签名链路:
```python
# 在 Python REPL 中手动测试
from arbitrage.engine.live_executor import LiveExecutor, RiskLimits
from arbitrage.book.l2 import BookRegistry
books = BookRegistry()
ex = LiveExecutor(books=books, dry_run=True) # 只签名不提交
```
**第 2 步**:把 `cli.py:87``dry_run=False` 保持不变(默认就是 False),但**先把 `ARB_MAX_BASKET_USD` 设为 `5`**
```ini
ARB_MAX_BASKET_USD=5
ARB_MAX_OPEN_BASKETS=1
```
**第 3 步**:观察 1–2 天无异常后,再逐步放大到默认值。
---
## 9. 监控与运维
### 9.1 日志
日志写在 `logs/arbitrage.jsonl`(结构化 JSON,每天 0 点轮转,保留 7 天)。
**实时查看**
```bash
# Linux/macOS
tail -f logs/arbitrage.jsonl | jq .
# Windows PowerShell
Get-Content logs\arbitrage.jsonl -Wait
```
**按事件过滤**
```bash
grep "new_market" logs/arbitrage.jsonl | tail -20
```
### 9.2 数据库
位置:`./arbitrage.db`SQLite + WAL 模式)
**直接查询**
```bash
sqlite3 arbitrage.db "SELECT status, COUNT(*) FROM baskets GROUP BY status;"
sqlite3 arbitrage.db "SELECT * FROM opportunities ORDER BY detected_at DESC LIMIT 10;"
```
**备份**
```bash
cp arbitrage.db arbitrage.db.bak-$(date +%Y%m%d)
```
**重置**(清空所有 paper 数据):
```bash
rm arbitrage.db
arb init
arb discover
```
### 9.3 进程监控
**检查运行中的 arb 进程**
```powershell
# Windows
Get-Process | Where-Object { $_.Name -eq "python" -and $_.Path -like "*Python312*" }
# Linux
ps aux | grep arb
```
**查看资源占用**
```bash
# 内存占用(正常 100-300 MB
Get-Process python | Select-Object Id, @{n='Mem(MB)';e={[int]$_.WorkingSet64/1MB}}
```
### 9.4 升级
```bash
cd arbitrage
git pull
pip install -e ".[dev]"
# 重启 arb scan / arb web
```
---
## 10. 故障排查
### 10.1 网络连通性
**症状**`httpx.ConnectTimeout``arb discover` 卡住。
**排查**
```powershell
Test-NetConnection -ComputerName gamma-api.polymarket.com -Port 443
```
如果失败,确认 `ARB_PROXY` 设置正确,或切换代理节点。
### 10.2 WebSocket 反复重连
**症状**`ws disconnect (TimeoutError); reconnecting in 1.0s` 大量重复。
**根因**WebSocket 没有走代理。
**修复**:确认 `.env` 中设置了 `ARB_PROXY=http://...`,代码会自动通过 HTTP CONNECT 隧道建立 WS 连接。
### 10.3 websockets 版本问题
**症状**`AttributeError: 'ClientConnection' object has no attribute 'recv_messages'`,或 `got an unexpected keyword argument 'proxy'`
**根因**`websockets 15.0` 与项目不兼容。
**修复**
```bash
pip install "websockets==14.2"
```
### 10.4 日志 PermissionError
**症状**`PermissionError: [WinError 32] 另一个程序正在使用此文件`
**根因**loguru 在 Windows 上的 50MB 轮转触发 `close() → os.rename()` 竞态。
**修复**(已默认配置):`rotation="1 day"` + `retention="7 days"`,避免高并发期轮转。
如果仍出现:
```powershell
# 清理残留进程
Get-Process python | Where-Object { $_.Path -like "*Python312*" } | Stop-Process -Force
```
### 10.5 残留进程占用文件
**症状**:杀掉 `arb scan` 后,新进程仍报 PermissionError。
**排查**
```powershell
Get-Process | Where-Object { $_.Name -eq "python" }
```
**清理**
```powershell
Get-Process python -ErrorAction SilentlyContinue | Stop-Process -Force
```
### 10.6 仪表板 404 / 端口冲突
**症状**:浏览器访问 `http://127.0.0.1:8000` 返回 404 或连接拒绝。
**排查**
```powershell
Test-NetConnection -ComputerName 127.0.0.1 -Port 8000
Get-NetTCPConnection -LocalPort 8000 -State Listen
```
**修复**:更换端口启动:
```bash
arb web --port 8888
```
### 10.7 没有任何套利机会
**症状**:仪表板显示 `recent opportunities` 为空或 net edge bps 全部 < 50。
**原因**
1. 当前 Polymarket 没有负空间(正常 — 套利机会稀少)
2. WS 数据未刷新(检查 `ws subscribed` 日志)
3. `ARB_MIN_NET_EDGE_BPS` 设得太高(默认值 50 合理)
---
## 11. 安全建议
### 11.1 私钥保护
- **永远不要把 `.env` 提交到 Git**(已在 `.gitignore` 中)
- 实盘私钥使用**专用钱包**,与其他资产隔离
- 服务器上 `.env` 文件权限设为 `chmod 600`Linux
- 考虑使用硬件钱包或多签
### 11.2 Web 仪表板
- 默认绑定 `127.0.0.1`**不要**改为 `0.0.0.0` 后暴露到公网
- `kill` / `unkill` 端点**无身份认证**,暴露即等于把钱包控制权交给攻击者
- 如果需要远程访问,使用 SSH 隧道:
```bash
ssh -L 8000:127.0.0.1:8000 user@server
```
### 11.3 依赖管理
- 锁定关键依赖版本,避免供应链风险:
```bash
pip install "websockets==14.2" "py-clob-client==0.34.6"
```
- 定期升级时检查 changelog
### 11.4 Live 模式额外建议
- 先用 ≥ $10 的小资金跑 1 周
- 每日检查 realized PnL,确认与 paper 结果趋势一致
- 设置价格告警(Discord webhook / Telegram bot 等)
- 监控 `daily_pnl` 表,日亏损接近 `ARB_DAILY_LOSS_STOP_USD` 时手动 kill
---
## 附录:环境变量参考
完整 `.env` 配置项:
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `ARB_MODE` | `paper` | `paper` / `live` |
| `ARB_CLOB_HOST` | `https://clob.polymarket.com` | CLOB API |
| `ARB_GAMMA_HOST` | `https://gamma-api.polymarket.com` | Gamma REST |
| `ARB_DATA_HOST` | `https://data-api.polymarket.com` | Data API |
| `ARB_WS_URL` | `wss://ws-subscriptions-clob.polymarket.com/ws/market` | WS 端点 |
| `ARB_POLYGON_RPC` | `https://polygon-rpc.com` | Polygon RPC |
| `ARB_PROXY` | __ | HTTP 代理(REST + WS 共享) |
| `ARB_PRIVATE_KEY` | __ | 钱包私钥(Live 必填) |
| `ARB_FUNDER_ADDRESS` | __ | 资金地址(Live 必填) |
| `ARB_SIGNATURE_TYPE` | `0` | 0=EOA / 1=proxy / 2=Safe |
| `ARB_API_KEY` | __ | CLOB L2 API key |
| `ARB_API_SECRET` | __ | CLOB L2 API secret |
| `ARB_API_PASSPHRASE` | __ | CLOB L2 API passphrase |
| `ARB_MIN_NET_EDGE_BPS` | `50` | 最低净边际(基点) |
| `ARB_MAX_BASKET_USD` | `50` | 单笔组合上限 USD |
| `ARB_MAX_OPEN_BASKETS` | `3` | 全局未结算组合上限 |
| `ARB_DAILY_LOSS_STOP_USD` | `100` | 日亏损止损 USD |
| `ARB_KILL_SWITCH_FILE` | `./KILL` | Kill switch 文件路径 |
| `ARB_PAPER_LATENCY_MS` | `250` | Paper 模拟延迟(毫秒) |
| `ARB_DB_PATH` | `./arbitrage.db` | SQLite 路径 |
| `ARB_WEB_HOST` | `127.0.0.1` | Web 监听地址 |
| `ARB_WEB_PORT` | `8000` | Web 端口 |
| `ARB_LOG_LEVEL` | `INFO` | 控制台日志级别 |
---
## 联系与反馈
- 项目主页:<https://matthewnyc2.github.io/arbitrage/>
- GitHub Issues<https://github.com/matthewnyc2/arbitrage/issues>
- API 参考:`docs/api/order-signing.md``docs/api/negrisk.md`
- 设计文档:`DESIGN.md`
+83
View File
@@ -0,0 +1,83 @@
[project]
name = "arbitrage"
version = "0.1.0"
description = "Polymarket NegRisk multi-outcome arbitrage scanner & executor (paper + live)"
requires-python = ">=3.12"
readme = "README.md"
dependencies = [
# Polymarket / chain
"py-clob-client>=0.20.0",
"web3>=7.0.0",
"eth-account>=0.13.0",
# Async IO
"httpx>=0.27.0",
"websockets>=13.0",
"tenacity>=9.0.0",
# Web UI
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"jinja2>=3.1.4",
"sse-starlette>=2.1.0",
# Persistence
"aiosqlite>=0.20.0",
# Sorted price ladders for the L2 book
"sortedcontainers>=2.4.0",
# Config / models / logs
"pydantic>=2.9.0",
"pydantic-settings>=2.5.0",
"loguru>=0.7.2",
"python-dotenv>=1.0.1",
"orjson>=3.10.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"pytest-cov>=5.0.0",
"ruff>=0.7.0",
"mypy>=1.13.0",
"respx>=0.21.0",
"freezegun>=1.5.0",
]
[project.scripts]
arb = "arbitrage.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["arbitrage"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "-ra --strict-markers --strict-config"
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "B", "UP", "SIM", "RUF", "ASYNC"]
ignore = [
"E501", # line length handled by formatter
"N817", # `Decimal as D` alias is an intentional test convention
"UP042", # str+Enum is fine; migrating to StrEnum is cosmetic
"N818", # RiskDenied is a flow-control exception, Error suffix unneeded
"SIM105", # try/except/pass reads fine for cancellation suppression
"SIM118", # x in dict.keys() is explicit; not always a win to remove
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["F401"] # test fixtures commonly import for side effects
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_ignores = true
plugins = ["pydantic.mypy"]
View File
+38
View File
@@ -0,0 +1,38 @@
"""Pytest fixtures — temp SQLite per test, fresh Settings singleton."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def _tmp_arb_env(monkeypatch, tmp_path: Path) -> Path:
"""Point every test at a throwaway DB + kill file."""
db_path = tmp_path / "arb.db"
kill_path = tmp_path / "KILL"
monkeypatch.setenv("ARB_DB_PATH", str(db_path))
monkeypatch.setenv("ARB_KILL_SWITCH_FILE", str(kill_path))
monkeypatch.setenv("ARB_MODE", "paper")
# Refresh the settings singleton and rebind any module-level imports.
import arbitrage.config as cfg
cfg.settings = cfg.Settings()
import arbitrage.db as dbmod
dbmod.settings = cfg.settings
import arbitrage.clients.polymarket_rest as rest
rest.settings = cfg.settings
import arbitrage.engine.paper_fills as pf
pf.settings = cfg.settings
import arbitrage.web.app as web
web.settings = cfg.settings
return tmp_path
@pytest.fixture
async def db(_tmp_arb_env):
from arbitrage.db import init_db
await init_db()
return _tmp_arb_env
+158
View File
@@ -0,0 +1,158 @@
"""CLI + scan-loop integration tests."""
from __future__ import annotations
import asyncio
from decimal import Decimal as D
import pytest
from arbitrage.book.l2 import BookRegistry
from arbitrage.clients.polymarket_rest import normalize_event, upsert_events
from arbitrage.engine.loop import hydrate_event_index, run_scan_loop
from arbitrage.engine.opportunity import EngineConfig, EventIndex, OpportunityEngine
from arbitrage.engine.paper_fills import PaperExecutor
from arbitrage.models import BasketStatus
def _raw_event(event_id: str = "0xmkt") -> dict:
return {
"id": event_id,
"slug": "slug",
"title": "Title",
"negRisk": True,
"negRiskMarketID": event_id,
"markets": [
{"clobTokenIds": ["A", "A2"], "groupItemTitle": "A"},
{"clobTokenIds": ["B", "B2"], "groupItemTitle": "B"},
],
}
@pytest.mark.asyncio
async def test_hydrate_event_index_loads_from_db(db) -> None:
ev = normalize_event(_raw_event())
assert ev is not None
await upsert_events([ev])
index = EventIndex()
n = await hydrate_event_index(index)
assert n == 1
assert index.event_for_token("A") is not None
@pytest.mark.asyncio
async def test_run_scan_loop_emits_basket_from_live_book(db) -> None:
ev = normalize_event(_raw_event())
assert ev is not None
await upsert_events([ev])
index = EventIndex()
await hydrate_event_index(index)
books = BookRegistry()
engine = OpportunityEngine(
books=books,
index=index,
config=EngineConfig(
min_net_edge_bps=50,
fees_per_share_usd=D("0"),
gas_per_basket_usd=D("0.10"),
max_basket_usd=D("50"),
),
)
executor = PaperExecutor(books=books, latency_ms=0)
# Kick the scan loop off and feed it two arbitrage-crossing books.
task = asyncio.create_task(
run_scan_loop(books=books, index=index, engine=engine, executor=executor)
)
# Give both the scan loop and engine.run() a chance to subscribe.
for _ in range(5):
await asyncio.sleep(0)
books.apply_snapshot("A", bids=[], asks=[(D("0.40"), D("200"))])
books.apply_snapshot("B", bids=[], asks=[(D("0.50"), D("200"))])
# Wait for a basket row to appear (bounded)
from arbitrage.db import db_conn
async def has_basket() -> bool:
async with db_conn() as conn:
cur = await conn.execute("SELECT COUNT(*) FROM baskets")
(n,) = await cur.fetchone()
return n > 0
for _ in range(40): # up to ~2s
if await has_basket():
break
await asyncio.sleep(0.05)
assert await has_basket(), "scan loop did not persist a basket"
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
def test_cli_help_exits_cleanly() -> None:
from arbitrage.cli import main
with pytest.raises(SystemExit) as exc:
main(["--help"])
assert exc.value.code == 0
def test_cli_init_creates_db(tmp_path, monkeypatch) -> None:
db_path = tmp_path / "cli.db"
monkeypatch.setenv("ARB_DB_PATH", str(db_path))
import arbitrage.config as cfg
cfg.settings = cfg.Settings()
import arbitrage.db as dbmod
dbmod.settings = cfg.settings
import arbitrage.cli as cli
cli.settings = cfg.settings
cli.main(["init"])
assert db_path.exists()
def test_cli_resolve_updates_basket(_tmp_arb_env) -> None:
"""End-to-end: set up a paper basket then run `arb resolve` to redeem it."""
async def setup() -> str:
from arbitrage.db import init_db
await init_db()
ev = normalize_event(_raw_event())
assert ev is not None
await upsert_events([ev])
index = EventIndex()
await hydrate_event_index(index)
books = BookRegistry()
books.apply_snapshot("A", bids=[], asks=[(D("0.40"), D("200"))])
books.apply_snapshot("B", bids=[], asks=[(D("0.50"), D("200"))])
engine = OpportunityEngine(
books=books, index=index,
config=EngineConfig(
min_net_edge_bps=50, fees_per_share_usd=D("0"),
gas_per_basket_usd=D("0.10"), max_basket_usd=D("50"),
),
)
opp = engine.evaluate(index.by_event_id["0xmkt"])
assert opp is not None
basket = await PaperExecutor(books=books, latency_ms=0).execute_now(opp)
assert basket is not None
return basket.id
async def check(basket_id: str) -> str:
from arbitrage.db import db_conn
async with db_conn() as conn:
(status,) = await (
await conn.execute(
"SELECT status FROM baskets WHERE id=?", (basket_id,)
)
).fetchone()
return status
basket_id = asyncio.run(setup())
from arbitrage.cli import main
rc = main(["resolve", "0xmkt", "--winner", "A"])
assert rc == 0
assert asyncio.run(check(basket_id)) == BasketStatus.REDEEMED.value
+124
View File
@@ -0,0 +1,124 @@
"""L2 book delta + registry tests."""
from __future__ import annotations
import asyncio
from decimal import Decimal as D
import pytest
from arbitrage.book.l2 import BookRegistry, LevelChange, LiveBook, Side
class TestLiveBook:
def test_snapshot_sets_best_bid_ask(self) -> None:
b = LiveBook(token_id="t")
b.apply_snapshot(
bids=[(D("0.10"), D("100")), (D("0.12"), D("50"))],
asks=[(D("0.20"), D("80")), (D("0.19"), D("40"))],
)
assert b.best_bid() == (D("0.12"), D("50"))
assert b.best_ask() == (D("0.19"), D("40"))
def test_snapshot_drops_zero_size_levels(self) -> None:
b = LiveBook(token_id="t")
b.apply_snapshot(bids=[(D("0.10"), D("0"))], asks=[(D("0.20"), D("10"))])
assert b.best_bid() is None
def test_delta_removes_level_at_zero(self) -> None:
b = LiveBook(token_id="t")
b.apply_snapshot(bids=[], asks=[(D("0.50"), D("10")), (D("0.60"), D("5"))])
b.apply_delta([LevelChange(price=D("0.50"), size=D("0"), side=Side.ASK)])
assert b.best_ask() == (D("0.60"), D("5"))
def test_delta_replaces_size_at_level(self) -> None:
b = LiveBook(token_id="t")
b.apply_snapshot(bids=[], asks=[(D("0.50"), D("10"))])
b.apply_delta([LevelChange(price=D("0.50"), size=D("3"), side=Side.ASK)])
assert b.best_ask() == (D("0.50"), D("3"))
def test_vwap_buy_walks_multiple_levels(self) -> None:
b = LiveBook(token_id="t")
b.apply_snapshot(
bids=[],
asks=[(D("0.19"), D("40")), (D("0.20"), D("80")), (D("0.21"), D("120"))],
)
result = b.vwap_buy(D("100"))
assert result is not None
vwap, filled, levels = result
assert vwap == D("0.196") # (40*0.19 + 60*0.20) / 100
assert filled == D("100")
assert levels == 2
def test_vwap_buy_partial_if_insufficient_depth(self) -> None:
b = LiveBook(token_id="t")
b.apply_snapshot(bids=[], asks=[(D("0.50"), D("10"))])
result = b.vwap_buy(D("100"))
assert result is not None
_, filled, _ = result
assert filled == D("10")
def test_vwap_buy_none_on_empty_book(self) -> None:
b = LiveBook(token_id="t")
assert b.vwap_buy(D("10")) is None
def test_to_snapshot_orders_bids_descending(self) -> None:
b = LiveBook(token_id="t")
b.apply_snapshot(
bids=[(D("0.10"), D("1")), (D("0.12"), D("2")), (D("0.11"), D("3"))],
asks=[],
)
snap = b.to_snapshot()
assert [lvl.price for lvl in snap.bids] == [D("0.12"), D("0.11"), D("0.10")]
def test_sequence_increments_on_updates(self) -> None:
b = LiveBook(token_id="t")
assert b.snapshots_applied == 0 and b.deltas_applied == 0
b.apply_snapshot(bids=[], asks=[(D("0.5"), D("1"))])
b.apply_delta([LevelChange(price=D("0.5"), size=D("2"), side=Side.ASK)])
b.apply_delta([LevelChange(price=D("0.5"), size=D("0"), side=Side.ASK)])
assert b.snapshots_applied == 1
assert b.deltas_applied == 2
class TestBookRegistry:
async def test_registry_publishes_updates_to_subscribers(self) -> None:
reg = BookRegistry()
events: list[str] = []
async def reader() -> None:
async for u in reg.updates():
events.append(u.reason)
if len(events) >= 2:
return
task = asyncio.create_task(reader())
await asyncio.sleep(0)
reg.apply_snapshot("x", bids=[], asks=[(D("0.5"), D("1"))])
reg.apply_delta("x", [LevelChange(price=D("0.5"), size=D("0"), side=Side.ASK)])
await asyncio.wait_for(task, timeout=1.0)
assert events == ["snapshot", "delta"]
def test_get_returns_none_for_unknown_token(self) -> None:
reg = BookRegistry()
assert reg.get("missing") is None
def test_book_creates_and_returns_same_instance(self) -> None:
reg = BookRegistry()
a = reg.book("t")
b = reg.book("t")
assert a is b
@pytest.mark.parametrize(
"raw,expected",
[
("BUY", Side.BID),
("buy", Side.BID),
("bid", Side.BID),
("SELL", Side.ASK),
("ASK", Side.ASK),
],
)
def test_level_change_from_raw(raw: str, expected: Side) -> None:
lc = LevelChange.from_raw({"price": "0.5", "size": "1", "side": raw})
assert lc.side is expected
+115
View File
@@ -0,0 +1,115 @@
"""Opportunity engine math tests."""
from __future__ import annotations
from decimal import Decimal as D
from arbitrage.book.l2 import BookRegistry
from arbitrage.engine.opportunity import EngineConfig, EventIndex, OpportunityEngine
from arbitrage.models import Event, Outcome
def _two_outcome_event() -> Event:
return Event(
id="e1",
slug="e1",
title="two-outcome",
is_neg_risk=True,
end_date=None,
outcomes=(
Outcome(token_id="A", name="A", outcome_index=0),
Outcome(token_id="B", name="B", outcome_index=1),
),
)
def _engine(
reg: BookRegistry,
index: EventIndex,
*,
min_bps: int = 50,
max_basket_usd: D = D("500"),
) -> OpportunityEngine:
return OpportunityEngine(
books=reg,
index=index,
config=EngineConfig(
min_net_edge_bps=min_bps,
fees_per_share_usd=D("0"),
gas_per_basket_usd=D("0.10"),
max_basket_usd=max_basket_usd,
),
)
class TestEvaluate:
def test_detects_arb_when_asks_sum_below_one(self) -> None:
ev = _two_outcome_event()
idx = EventIndex()
idx.upsert(ev)
reg = BookRegistry()
reg.apply_snapshot("A", bids=[], asks=[(D("0.40"), D("200"))])
reg.apply_snapshot("B", bids=[], asks=[(D("0.50"), D("200"))])
opp = _engine(reg, idx).evaluate(ev)
assert opp is not None
assert opp.event_id == "e1"
assert opp.net_edge_bps >= 50
assert opp.max_baskets > 0
def test_rejects_when_asks_sum_above_one(self) -> None:
ev = _two_outcome_event()
idx = EventIndex()
idx.upsert(ev)
reg = BookRegistry()
reg.apply_snapshot("A", bids=[], asks=[(D("0.70"), D("200"))])
reg.apply_snapshot("B", bids=[], asks=[(D("0.50"), D("200"))])
assert _engine(reg, idx).evaluate(ev) is None
def test_rejects_when_any_leg_has_no_asks(self) -> None:
ev = _two_outcome_event()
idx = EventIndex()
idx.upsert(ev)
reg = BookRegistry()
reg.apply_snapshot("A", bids=[], asks=[])
reg.apply_snapshot("B", bids=[], asks=[(D("0.50"), D("200"))])
assert _engine(reg, idx).evaluate(ev) is None
def test_rejects_when_edge_below_threshold(self) -> None:
ev = _two_outcome_event()
idx = EventIndex()
idx.upsert(ev)
reg = BookRegistry()
# Sum = 0.995 -> 50bps gross, net will be below 50 after gas
reg.apply_snapshot("A", bids=[], asks=[(D("0.495"), D("200"))])
reg.apply_snapshot("B", bids=[], asks=[(D("0.500"), D("200"))])
assert _engine(reg, idx, min_bps=50).evaluate(ev) is None
def test_depth_clips_basket_count(self) -> None:
ev = _two_outcome_event()
idx = EventIndex()
idx.upsert(ev)
reg = BookRegistry()
# Leg A is very thin
reg.apply_snapshot("A", bids=[], asks=[(D("0.40"), D("12"))])
reg.apply_snapshot("B", bids=[], asks=[(D("0.50"), D("10000"))])
opp = _engine(reg, idx).evaluate(ev)
assert opp is not None
assert opp.max_baskets <= D("12")
def test_vwap_degrades_with_size(self) -> None:
"""At large sizes we consume worse levels; edge per basket must shrink."""
ev = _two_outcome_event()
idx = EventIndex()
idx.upsert(ev)
reg = BookRegistry()
# Both legs: cheap top, expensive deep levels
reg.apply_snapshot(
"A", bids=[], asks=[(D("0.40"), D("10")), (D("0.48"), D("10000"))]
)
reg.apply_snapshot(
"B", bids=[], asks=[(D("0.50"), D("10")), (D("0.52"), D("10000"))]
)
opp = _engine(reg, idx).evaluate(ev)
assert opp is not None
# At size 10, sum_vwap = 0.90. At larger sizes it'll rise toward 1.00.
# The engine chose the size maximizing expected profit, so sum is <= 1.0.
assert opp.sum_vwap_asks <= D("1.0")
+144
View File
@@ -0,0 +1,144 @@
"""Paper executor + resolution tests."""
from __future__ import annotations
from decimal import Decimal as D
import pytest
from arbitrage.book.l2 import BookRegistry
from arbitrage.db import db_conn
from arbitrage.engine.opportunity import EngineConfig, EventIndex, OpportunityEngine
from arbitrage.engine.paper_fills import (
PaperExecutor,
mark_resolution,
simulate_leg_fill,
)
from arbitrage.models import BasketStatus, Event, Outcome
def _setup_event_and_books():
ev = Event(
id="evt",
slug="s",
title="t",
is_neg_risk=True,
end_date=None,
outcomes=(
Outcome(token_id="A", name="A", outcome_index=0),
Outcome(token_id="B", name="B", outcome_index=1),
),
)
idx = EventIndex()
idx.upsert(ev)
reg = BookRegistry()
reg.apply_snapshot("A", bids=[], asks=[(D("0.40"), D("200"))])
reg.apply_snapshot("B", bids=[], asks=[(D("0.50"), D("200"))])
eng = OpportunityEngine(
books=reg,
index=idx,
config=EngineConfig(
min_net_edge_bps=50,
fees_per_share_usd=D("0"),
gas_per_basket_usd=D("0.10"),
max_basket_usd=D("100"),
),
)
return ev, reg, eng
def test_simulate_leg_fill_partial_depth() -> None:
from arbitrage.book.l2 import LiveBook
b = LiveBook(token_id="t")
b.apply_snapshot(bids=[], asks=[(D("0.50"), D("5"))])
res = simulate_leg_fill(b, D("100"))
assert res.filled == D("5")
assert res.vwap_price == D("0.50")
def test_simulate_leg_fill_empty_book() -> None:
from arbitrage.book.l2 import LiveBook
b = LiveBook(token_id="t")
res = simulate_leg_fill(b, D("10"))
assert res.filled == 0
assert res.levels_consumed == 0
@pytest.mark.asyncio
async def test_successful_basket_reaches_pending_resolution(db) -> None:
ev, reg, eng = _setup_event_and_books()
opp = eng.evaluate(ev)
assert opp is not None
execr = PaperExecutor(books=reg, latency_ms=0)
basket = await execr.execute_now(opp)
assert basket is not None
assert basket.status is BasketStatus.PENDING_RESOLUTION
assert basket.basket_count == opp.max_baskets
assert basket.total_cost_usd > 0
@pytest.mark.asyncio
async def test_vanishing_depth_produces_failed_basket(db) -> None:
ev, reg, eng = _setup_event_and_books()
opp = eng.evaluate(ev)
assert opp is not None
reg.apply_snapshot("A", bids=[], asks=[(D("0.40"), D("1"))])
execr = PaperExecutor(books=reg, latency_ms=0)
basket = await execr.execute_now(opp)
assert basket is not None
assert basket.status is BasketStatus.FAILED
@pytest.mark.asyncio
async def test_resolution_redeems_winning_basket(db) -> None:
ev, reg, eng = _setup_event_and_books()
opp = eng.evaluate(ev)
assert opp is not None
execr = PaperExecutor(books=reg, latency_ms=0)
basket = await execr.execute_now(opp)
assert basket is not None
updated = await mark_resolution("evt", winning_token_id="A")
assert updated == 1
async with db_conn() as conn:
row = await (
await conn.execute(
"SELECT status, realized_pnl_usd FROM baskets WHERE id=?", (basket.id,)
)
).fetchone()
assert row[0] == BasketStatus.REDEEMED.value
assert D(row[1]) > 0
@pytest.mark.asyncio
async def test_invalid_resolution_marks_basket_loss(db) -> None:
ev, reg, eng = _setup_event_and_books()
opp = eng.evaluate(ev)
assert opp is not None
execr = PaperExecutor(books=reg, latency_ms=0)
basket = await execr.execute_now(opp)
assert basket is not None
updated = await mark_resolution("evt", winning_token_id=None)
assert updated == 1
async with db_conn() as conn:
row = await (
await conn.execute(
"SELECT status, realized_pnl_usd FROM baskets WHERE id=?", (basket.id,)
)
).fetchone()
assert row[0] == BasketStatus.INVALID.value
assert D(row[1]) < 0
@pytest.mark.asyncio
async def test_resolution_is_idempotent(db) -> None:
ev, reg, eng = _setup_event_and_books()
opp = eng.evaluate(ev)
assert opp is not None
await PaperExecutor(books=reg, latency_ms=0).execute_now(opp)
first = await mark_resolution("evt", winning_token_id="A")
second = await mark_resolution("evt", winning_token_id="A")
assert first == 1
assert second == 0 # nothing pending anymore
+121
View File
@@ -0,0 +1,121 @@
"""REST discovery normalization + persistence tests."""
from __future__ import annotations
from decimal import Decimal as D
import pytest
from arbitrage.clients.polymarket_rest import (
mark_inactive,
normalize_event,
upsert_events,
)
from arbitrage.db import db_conn
def _good_raw() -> dict:
return {
"id": 12345,
"slug": "world-cup",
"title": "World Cup",
"negRisk": True,
"negRiskMarketID": "0xmkt",
"endDate": "2026-07-20T00:00:00Z",
"markets": [
{"conditionId": "0xc1", "clobTokenIds": ["11", "22"], "groupItemTitle": "Brazil"},
{"conditionId": "0xc2", "clobTokenIds": '["33","44"]', "groupItemTitle": "France"},
{"conditionId": "0xc3", "clobTokenIds": ["55", "66"], "groupItemTitle": "Argentina"},
],
}
class TestNormalize:
def test_accepts_well_formed_event(self) -> None:
ev = normalize_event(_good_raw())
assert ev is not None
assert ev.id == "0xmkt"
assert len(ev.outcomes) == 3
assert [o.token_id for o in ev.outcomes] == ["11", "33", "55"]
assert [o.name for o in ev.outcomes] == ["Brazil", "France", "Argentina"]
assert ev.end_date is not None and ev.end_date.year == 2026
def test_rejects_non_neg_risk(self) -> None:
raw = _good_raw() | {"negRisk": False}
assert normalize_event(raw) is None
def test_rejects_too_few_outcomes(self) -> None:
raw = _good_raw()
raw["markets"] = raw["markets"][:1]
assert normalize_event(raw) is None
def test_rejects_closed_child_market(self) -> None:
raw = _good_raw()
raw["markets"][0]["closed"] = True
assert normalize_event(raw) is None
def test_rejects_duplicate_token_ids(self) -> None:
raw = _good_raw()
raw["markets"][1]["clobTokenIds"] = ["11", "99"] # dup of market[0]
assert normalize_event(raw) is None
def test_parses_json_string_token_ids(self) -> None:
raw = _good_raw()
raw["markets"][0]["clobTokenIds"] = '["11","22"]'
ev = normalize_event(raw)
assert ev is not None
assert ev.outcomes[0].token_id == "11"
def test_rejects_malformed_token_ids_json(self) -> None:
raw = _good_raw()
raw["markets"][0]["clobTokenIds"] = "not-json{"
assert normalize_event(raw) is None
def test_falls_back_to_event_id_when_neg_risk_market_id_missing(self) -> None:
raw = _good_raw()
del raw["negRiskMarketID"]
ev = normalize_event(raw)
assert ev is not None
assert ev.id == "12345"
@pytest.mark.asyncio
async def test_upsert_is_idempotent(db) -> None:
ev = normalize_event(_good_raw())
assert ev is not None
assert await upsert_events([ev]) == 1
assert await upsert_events([ev]) == 1
async with db_conn() as conn:
(count,) = await (await conn.execute("SELECT COUNT(*) FROM events")).fetchone()
(outcome_count,) = await (
await conn.execute("SELECT COUNT(*) FROM outcomes")
).fetchone()
assert count == 1
assert outcome_count == 3
@pytest.mark.asyncio
async def test_mark_inactive_flips_dropped_events(db) -> None:
ev = normalize_event(_good_raw())
assert ev is not None
await upsert_events([ev])
dropped = await mark_inactive({"kept-other-event"})
assert dropped == 1
async with db_conn() as conn:
(active,) = await (
await conn.execute("SELECT active FROM events WHERE id=?", (ev.id,))
).fetchone()
assert active == 0
@pytest.mark.asyncio
async def test_mark_inactive_preserves_kept_events(db) -> None:
ev = normalize_event(_good_raw())
assert ev is not None
await upsert_events([ev])
dropped = await mark_inactive({ev.id})
assert dropped == 0
async with db_conn() as conn:
(active,) = await (
await conn.execute("SELECT active FROM events WHERE id=?", (ev.id,))
).fetchone()
assert active == 1
+99
View File
@@ -0,0 +1,99 @@
"""Live executor risk gate tests — no py-clob-client required."""
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal as D
from pathlib import Path
import pytest
from arbitrage.db import db_conn
from arbitrage.engine.live_executor import RiskDenied, RiskLimits, risk_gate
from arbitrage.models import Event, Opportunity, OpportunityLeg, Outcome
def _opportunity(cost_per_share: D, size: D) -> Opportunity:
ev = Event(
id="e",
slug="e",
title="t",
is_neg_risk=True,
end_date=None,
outcomes=(
Outcome(token_id="A", name="A", outcome_index=0),
Outcome(token_id="B", name="B", outcome_index=1),
),
)
return Opportunity.from_legs(
detected_at=datetime.now(UTC),
event=ev,
legs=(
OpportunityLeg(
token_id="A",
outcome_name="A",
outcome_index=0,
vwap_price=cost_per_share / D(2),
size=size,
levels_consumed=1,
),
OpportunityLeg(
token_id="B",
outcome_name="B",
outcome_index=1,
vwap_price=cost_per_share / D(2),
size=size,
levels_consumed=1,
),
),
fees_per_share=D("0"),
gas_per_basket_usd=D("0.10"),
max_baskets=size,
)
def _limits(**overrides) -> RiskLimits:
base = dict(
max_basket_usd=D("200"),
max_open_baskets=3,
max_open_baskets_per_event=1,
daily_loss_stop_usd=D("100"),
kill_switch_file=Path("/nope/does-not-exist"),
)
base.update(overrides)
return RiskLimits(**base)
@pytest.mark.asyncio
async def test_risk_gate_accepts_within_limits(db) -> None:
opp = _opportunity(D("0.90"), D("100")) # cost = $90
await risk_gate(opp, _limits())
@pytest.mark.asyncio
async def test_risk_gate_rejects_oversized_basket(db) -> None:
opp = _opportunity(D("0.90"), D("1000")) # cost = $900
with pytest.raises(RiskDenied, match="basket cost"):
await risk_gate(opp, _limits(max_basket_usd=D("500")))
@pytest.mark.asyncio
async def test_risk_gate_rejects_when_kill_switch_present(db, tmp_path) -> None:
kill = tmp_path / "KILL"
kill.touch()
opp = _opportunity(D("0.90"), D("100"))
with pytest.raises(RiskDenied, match="kill switch"):
await risk_gate(opp, _limits(kill_switch_file=kill))
@pytest.mark.asyncio
async def test_risk_gate_rejects_daily_loss_stop(db) -> None:
async with db_conn() as conn:
today = datetime.now(UTC).date().isoformat()
await conn.execute(
"INSERT INTO daily_pnl (date, live_pnl_usd) VALUES (?, ?)",
(today, "-150.00"),
)
await conn.commit()
opp = _opportunity(D("0.90"), D("100"))
with pytest.raises(RiskDenied, match="daily loss stop"):
await risk_gate(opp, _limits(daily_loss_stop_usd=D("100")))
+53
View File
@@ -0,0 +1,53 @@
"""FastAPI dashboard endpoint smoke tests."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from arbitrage.web.app import create_app
@pytest.fixture
def client(_tmp_arb_env):
app = create_app()
with TestClient(app) as c:
yield c
def test_index_renders(client) -> None:
r = client.get("/")
assert r.status_code == 200
assert "arbitrage" in r.text
assert "paper" in r.text
def test_pnl_fragment_renders(client) -> None:
r = client.get("/fragments/pnl")
assert r.status_code == 200
assert "paper pnl" in r.text
def test_opportunities_fragment_empty_state(client) -> None:
r = client.get("/fragments/opportunities")
assert r.status_code == 200
assert "no opportunities yet" in r.text
def test_baskets_fragment_empty_state(client) -> None:
r = client.get("/fragments/baskets")
assert r.status_code == 200
assert "no baskets yet" in r.text
def test_kill_toggle_round_trip(client, _tmp_arb_env) -> None:
import arbitrage.config as cfg
assert not cfg.settings.kill_switch_file.exists()
r = client.post("/kill")
assert r.status_code == 200
assert "KILL SWITCH ACTIVE" in r.text
assert cfg.settings.kill_switch_file.exists()
r = client.post("/unkill")
assert r.status_code == 200
assert "KILL SWITCH ACTIVE" not in r.text
assert not cfg.settings.kill_switch_file.exists()
+96
View File
@@ -0,0 +1,96 @@
"""Polymarket CLOB WS message dispatch (parse-only, no live socket)."""
from __future__ import annotations
from decimal import Decimal as D
from arbitrage.book.l2 import BookRegistry
from arbitrage.clients.polymarket_ws import MarketChannel, shard_tokens
def _channel(reg: BookRegistry) -> MarketChannel:
return MarketChannel(["tok1", "tok2"], registry=reg)
def test_book_snapshot_message_populates_registry() -> None:
reg = BookRegistry()
_channel(reg)._dispatch(
{
"event_type": "book",
"asset_id": "tok1",
"market": "m1",
"bids": [{"price": "0.10", "size": "100"}, {"price": "0.12", "size": "50"}],
"asks": [{"price": "0.20", "size": "80"}],
"timestamp": "1700000000000",
"hash": "0xaa",
}
)
book = reg.get("tok1")
assert book is not None
assert book.best_bid() == (D("0.12"), D("50"))
assert book.best_ask() == (D("0.20"), D("80"))
assert book.last_hash == "0xaa"
def test_price_change_applies_delta_per_asset() -> None:
reg = BookRegistry()
ch = _channel(reg)
ch._dispatch(
{
"event_type": "book",
"asset_id": "tok1",
"market": "m",
"bids": [],
"asks": [{"price": "0.20", "size": "80"}],
"timestamp": "1",
}
)
ch._dispatch(
{
"event_type": "price_change",
"market": "m",
"timestamp": "2",
"price_changes": [
{"asset_id": "tok1", "price": "0.20", "size": "0", "side": "SELL"},
{"asset_id": "tok1", "price": "0.19", "size": "30", "side": "SELL"},
{"asset_id": "tok1", "price": "0.11", "size": "60", "side": "BUY"},
],
}
)
book = reg.get("tok1")
assert book.best_ask() == (D("0.19"), D("30"))
assert book.best_bid() == (D("0.11"), D("60"))
def test_zero_size_removes_level() -> None:
reg = BookRegistry()
ch = _channel(reg)
ch._dispatch(
{
"event_type": "book",
"asset_id": "tok1",
"bids": [],
"asks": [{"price": "0.20", "size": "10"}],
"timestamp": "1",
}
)
ch._dispatch(
{
"event_type": "price_change",
"timestamp": "2",
"price_changes": [
{"asset_id": "tok1", "price": "0.20", "size": "0", "side": "SELL"}
],
}
)
assert reg.get("tok1").best_ask() is None
def test_unknown_event_type_is_ignored() -> None:
reg = BookRegistry()
_channel(reg)._dispatch({"event_type": "who_knows", "asset_id": "tok1"})
assert reg.get("tok1") is None
def test_shard_tokens_splits_evenly() -> None:
shards = shard_tokens([str(i) for i in range(250)], shard_size=100)
assert [len(s) for s in shards] == [100, 100, 50]