Building a Polymarket AI Trading Bot with Claude

Polymarket is a prediction market running on Polygon. You buy shares in outcomes — YES or NO — and get paid $1 per share if you're right. The price of a share is the market's implied probability. If YES is trading at $0.62, the crowd thinks there's a 62% chance the event resolves YES. Mispricings exist. The question is whether you can find them faster than the market corrects them.

This bot tries to. It scans sports markets, pulls live team and player stats, sends everything to Claude Haiku for mispricing analysis, and gates real-money execution behind a Claude Opus confirmation step. Here's how the whole thing fits together.

Architecture

Seven modules, each with a single responsibility:

main.py          — orchestration loop, CLI flags, Rich display
config.py        — env-based config, strategy params
scanner.py       — Gamma API market fetch + filter
stats_fetcher.py — ESPN API: team records, player stats, recent form
analyzer.py      — Claude Haiku batch analysis → TradeSignal list
executor.py      — Opus confirmation + CLOB order placement
client.py        — thin py-clob-client wrapper
price_fetcher.py — Chainlink BTC/USD feed via Web3

The main loop is straightforward: scan → enrich → analyse → (confirm → execute). Everything is synchronous and single-threaded. The scan interval defaults to 30 seconds.

Market Scanning

Polymarket exposes a public REST API called Gamma at gamma-api.polymarket.com. The scanner hits the /markets endpoint, pulls up to 200 markets sorted by volume descending, and filters down to anything closing within the configured window (default: 48 hours). Anything further out gets skipped — the edge from a mispricing erodes fast, and short-dated markets have the most predictable resolution criteria.

params = {
    "active": True,
    "closed": False,
    "limit": 200,
    "order": "volume",
    "ascending": False
}
resp = requests.get(f"{GAMMA_BASE}/markets", params=params)
markets = resp.json()

Each market object contains the question string, outcomePrices (a JSON array of current best bids as decimal probabilities), and the clobTokenIds needed to place orders. The scanner extracts those three fields and hands them upstream.

Stats Enrichment

Raw market questions are useless without context. A question like "Will the Lakers win tonight?" means something different if they're 3-7 in their last 10 vs. 9-1. The stats_fetcher module parses team names out of the question string and queries ESPN's undocumented but stable public API endpoints:

# Team record + last-5 form
https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/teams/{team_id}

# Player season stats
https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/athletes/{athlete_id}/stats

# Schedule (recent results + upcoming)
https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/teams/{team_id}/schedule

The module maps common team name variants to ESPN team IDs, covering NBA, MLB, and NFL. The returned context object bundles win/loss record, last-5 game outcomes, and any relevant player stats (usage rate, shooting %, ERA, etc.) into a dict that gets injected into the Haiku prompt.

Claude Haiku Analysis

The analyser takes the full list of filtered markets and issues a single batched request to claude-haiku-4-5. Batching matters here — sports cards often run 20–40 markets simultaneously, and one API call per market would hit rate limits and add latency.

The system prompt is the core of the logic. It instructs Haiku to act as a quantitative sports analyst, reason about true probability vs. market price using the provided stats, and output a structured JSON array:

{
  "markets": [
    {
      "question": "Will the Celtics cover -4.5 vs. the Knicks?",
      "decision": "YES",
      "confidence": "HIGH",
      "estimated_probability": 0.71,
      "market_price": 0.58,
      "edge": 0.13,
      "reasoning": "Celtics 8-2 L10, Knicks missing Brunson (ankle). ..."
    },
    ...
  ]
}

The user message injects the market list with current prices and the enriched stats block per market. Haiku returns one entry per market. Anything with decision == "SKIP" or confidence == "LOW" is dropped immediately. The remaining signals are filtered by MIN_EDGE (default 7%) — only trades where Haiku's estimated probability beats the market by at least that margin survive.

signals = [
    s for s in raw_signals
    if s.decision != "SKIP"
    and s.confidence in ("MEDIUM", "HIGH")
    and s.edge >= config.MIN_EDGE
]

Dual-Model Confirmation

Haiku is fast and cheap but it can hallucinate confidence. Before any live order is placed, the executor re-submits each signal to claude-opus-4-6 with a deliberately conservative prompt: assume the market has information you don't, and only approve if the edge is robust to a 5% downward revision of the estimated probability.

OPUS_SYSTEM = """You are a conservative risk manager reviewing a proposed prediction market trade.
Assume the market price reflects information you may not have.
Apply a 5% haircut to the submitted estimated probability.
Approve only if the edge remains positive after that adjustment.
Reply with a JSON object: {"approved": true/false, "reason": "..."}"""

Opus runs synchronously per signal. If it returns approved: false, the trade is skipped and the reason is logged. This adds latency — Opus is slower than Haiku — but markets with 48-hour windows don't require millisecond execution. The confirmation round-trip is worth the cost of not losing money on a hallucinated edge.

Order Execution via CLOB

Polymarket's order book is the CLOB (Collaborative Limit Order Book), a centralised off-chain matching engine that settles on Polygon. The Python client is py-clob-client. Authentication uses ECDSA-signed API keys derived from a wallet private key.

# One-time setup: derive API credentials from wallet key
python main.py --setup

This writes CLOB_API_KEY, CLOB_API_SECRET, and CLOB_API_PASSPHRASE to .env. The executor then constructs a MarketOrderArgs object with the token ID for the target outcome and a USDC amount capped at MAX_POSITION_SIZE (default $50):

order_args = MarketOrderArgs(
    token_id=signal.token_id,
    amount=min(signal.position_size, config.MAX_POSITION_SIZE)
)
signed_order = client.create_market_order(order_args)
resp = client.post_order(signed_order, OrderType.FOK)  # Fill-or-kill

Orders are fill-or-kill. If the book can't fill the full amount at the current price, the order is cancelled rather than partially filled at a worse price. Slippage on low-volume markets can be brutal, and a partial fill at the wrong price erases the edge.

Blockchain Price Feed

price_fetcher.py reads the BTC/USD price from Chainlink's aggregator contract on Polygon via Web3.py. This is less about trading BTC and more about having a live on-chain data source wired in — useful for markets around crypto price levels or as a sanity check that the Polygon RPC is live before placing orders.

CHAINLINK_BTC_USD = "0xc907E116054Ad103354f2D350FD2514433D57F6f"
ABI = [{"inputs": [], "name": "latestRoundData", "outputs": [...], "type": "function"}]

w3 = Web3(Web3.HTTPProvider(config.POLYGON_RPC_URL))
contract = w3.eth.contract(address=CHAINLINK_BTC_USD, abi=ABI)
_, answer, _, _, _ = contract.functions.latestRoundData().call()
price = answer / 1e8  # Chainlink returns 8 decimal fixed point

Configuration

All strategy parameters live in config.py and are overridable via environment variables. The defaults are conservative:

MIN_EDGE                = 0.07   # 7% minimum price discrepancy
MIN_CONFIDENCE          = MEDIUM # accept MEDIUM or HIGH from Haiku
MAX_POSITION_SIZE       = 50     # USD per trade
CLOSING_WITHIN_HOURS    = 48     # only trade markets closing within 48h
SCAN_INTERVAL_SECONDS   = 30     # rescan frequency
DRY_RUN                 = true   # no real money unless explicitly disabled

Dry-run mode runs the full pipeline — scan, enrich, analyse, Opus confirm — but skips the actual CLOB post. Useful for watching what the bot would do without risking capital.

Running It

# Install deps
pip install -r requirements.txt

# Generate CLOB credentials from wallet private key
python main.py --setup

# Dry-run (default) — watch signals without trading
python main.py

# Single scan pass and exit
python main.py --scan-once

# Live trading
DRY_RUN=false python main.py

# Single live scan
DRY_RUN=false python main.py --scan-once

What I'd Change

The ESPN API calls are synchronous and happen per-market. At 40 markets that's 40 sequential HTTP requests before Haiku even sees the data. Async would cut the enrichment phase from ~8s to under a second. The stats parsing is also brittle — team name extraction is regex-based and fails on anything the regex wasn't written for (international players, traded players, mid-season name changes).

The Opus confirmation adds 2–4 seconds per signal. For a 30-second scan interval that's fine, but if you wanted to run on shorter windows it becomes a bottleneck. One option is to only call Opus when the position size exceeds a threshold — cheap Haiku confirmation below that, Opus confirmation above it.

Position sizing is flat. Every trade gets the same dollar amount regardless of edge magnitude or confidence level. A simple Kelly fraction using the Haiku-estimated edge and probability would size positions more aggressively when the signal is strong and conservatively when it's marginal.