first commit
This commit is contained in:
@@ -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=
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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/
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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)**
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`.
|
||||||
@@ -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
@@ -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 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 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 <negRiskMarketID> --winner <winning_token_id>
|
||||||
|
arb resolve <negRiskMarketID> --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 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 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 20–50% 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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.1.0"
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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).
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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 %}
|
||||||
|
· <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>
|
||||||
@@ -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>
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Prevent Jekyll from trying to process the site; we ship plain HTML/CSS/JS.
|
||||||
|
theme: null
|
||||||
|
plugins: []
|
||||||
|
include:
|
||||||
|
- assets
|
||||||
@@ -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` ~ 200–250 k gas
|
||||||
|
- `mergePositions` ~ 200–250 k gas
|
||||||
|
- `redeemPositions(N=2)` ~ 150–220 k gas (single binary)
|
||||||
|
- `convertPositions` ~ 250–400 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)
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
/*
|
||||||
|
* Live Polymarket arbitrage scanner — portfolio demo.
|
||||||
|
*
|
||||||
|
* What this does, in plain English:
|
||||||
|
* - Loads a list of 6–8 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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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 “vig” — 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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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 &
|
||||||
|
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
@@ -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
@@ -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 默认 7890,V2Ray 默认 10809,SSR 默认 1080。
|
||||||
|
>
|
||||||
|
> 配置完成后 httpx(REST)和 websockets(WS)都会通过代理连接。
|
||||||
|
|
||||||
|
### 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`
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")))
|
||||||
@@ -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()
|
||||||
@@ -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]
|
||||||
Reference in New Issue
Block a user