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.