Polymarket processes over $1 billion in monthly trading volume across sports, politics, and crypto events. The markets are liquid, 24/7, and increasingly driven by algorithmic traders. If you have a model that predicts outcomes better than the crowd, you can profit by trading against the market price.
This guide walks you through building an automated Polymarket trading bot in Python — from fetching market data to detecting edge to executing trades programmatically. We'll use real code, real API endpoints, and a real edge-detection strategy based on calibrated win probability models.
You'll need Python 3.10+, a Polymarket account with API access, and a fair-value model. For this tutorial, we'll use ZenHodl's prediction API as the fair-value source — it provides calibrated win probabilities for 10 sports updated in real-time.
# Install dependencies
pip install requests websockets python-dotenv
# .env file
POLYMARKET_API_KEY=your_key_here
ZENHODL_API_KEY=your_key_here
import os
import time
import requests
from dotenv import load_dotenv
load_dotenv()
POLY_API = "https://clob.polymarket.com"
ZENHODL_API = "https://zenhodl.net"
ZENHODL_KEY = os.environ["ZENHODL_API_KEY"]
Polymarket's CLOB (Central Limit Order Book) API provides real-time bid/ask prices for every market. For sports events, each game has two outcome tokens (home team and away team) priced between 0c and 100c.
def get_polymarket_price(token_id: str) -> dict:
"""Fetch current bid/ask for a Polymarket token."""
resp = requests.get(
f"{POLY_API}/book",
params={"token_id": token_id},
timeout=10,
)
resp.raise_for_status()
book = resp.json()
best_bid = float(book["bids"][0]["price"]) if book.get("bids") else 0
best_ask = float(book["asks"][0]["price"]) if book.get("asks") else 1
spread = best_ask - best_bid
return {
"bid": best_bid,
"ask": best_ask,
"spread": spread,
"mid": (best_bid + best_ask) / 2,
}
Key insight: The mid-price (average of bid and ask) is the market's implied probability. If the mid is 0.65, the market thinks the team has a 65% chance of winning. Your job is to find cases where your model disagrees with the market.
The core of any trading bot is the fair value model — your independent estimate of the true probability. You can build your own model (see our previous tutorial on building a sports prediction API), or use a production-grade API like ZenHodl.
def get_fair_value(game_id: str) -> dict:
"""Get calibrated fair probability from ZenHodl API."""
resp = requests.get(
f"{ZENHODL_API}/v1/games",
headers={"X-API-Key": ZENHODL_KEY},
params={"game_id": game_id},
timeout=10,
)
resp.raise_for_status()
games = resp.json()
if not games:
return None
game = games[0]
return {
"home_team": game["home_team"],
"away_team": game["away_team"],
"home_wp": game["home_wp"], # fair probability (0-1)
"away_wp": 1 - game["home_wp"],
"sport": game["sport"],
"status": game["status"],
}
ZenHodl's predictions are calibrated using isotonic regression — when the model says 70%, the team actually wins ~70% of the time. This calibration accuracy is critical for trading: an uncalibrated model might say 70% when the real probability is 55%, causing you to overpay for every trade. See their methodology page for details on how the models are trained and calibrated.
Edge is the difference between your fair value and the market price. If your model says a team has a 72% chance but the market is selling at 65c, you have +7c of edge. The question is: how much edge do you need before it's worth trading?
def detect_edge(fair_wp: float, market_ask: float, min_edge: float = 0.08) -> dict:
"""
Detect if there's tradeable edge.
Args:
fair_wp: Your model's probability (0-1)
market_ask: Current ask price on Polymarket (0-1)
min_edge: Minimum edge threshold (default 8%)
Returns:
dict with edge info, or None if no edge
"""
edge = fair_wp - market_ask
if edge < min_edge:
return None
# Expected value per dollar wagered
ev = fair_wp * (1 - market_ask) - (1 - fair_wp) * market_ask
return {
"edge_c": round(edge * 100, 1), # in cents
"ev_per_dollar": round(ev, 4),
"fair_wp": round(fair_wp, 3),
"market_ask": round(market_ask, 3),
"signal": "BUY",
}
Warning: Small edges (under 5c) are usually noise, not signal. In our live trading results, edges under 8c produced breakeven-or-worse returns. Only edges of 8c+ showed consistent profitability. Set your min_edge conservatively — it's better to miss a marginal trade than to take a losing one.
Once you detect edge, the bot places a limit order on Polymarket's CLOB. We use a limit order (not market) to control slippage:
def place_order(token_id: str, side: str, price: float, size: float) -> dict:
"""Place a limit order on Polymarket.
Args:
token_id: The outcome token to trade
side: "BUY" or "SELL"
price: Limit price (0-1)
size: Number of contracts
"""
order = {
"tokenID": token_id,
"side": side,
"price": str(price),
"size": str(size),
"type": "GTC", # Good-til-cancelled
}
resp = requests.post(
f"{POLY_API}/order",
json=order,
headers={
"Authorization": f"Bearer {os.environ['POLYMARKET_API_KEY']}",
"Content-Type": "application/json",
},
timeout=15,
)
resp.raise_for_status()
return resp.json()
Risk management is where most bots fail. Here are the three rules our production bots follow:
def kelly_size(fair_wp: float, market_price: float,
bankroll: float, kelly_fraction: float = 0.25) -> float:
"""Half-Kelly position sizing.
Full Kelly maximizes long-run growth but has brutal drawdowns.
Quarter-Kelly (default) trades growth for stability.
"""
p = fair_wp
q = 1 - p
b = (1 - market_price) / market_price # odds
raw_kelly = (p * b - q) / b
if raw_kelly <= 0:
return 0
return bankroll * min(raw_kelly, 0.25) * kelly_fraction
# Never risk more than 5% of bankroll on one game
MAX_GAME_EXPOSURE = 0.05
# One position per game (no pyramiding)
MAX_POSITIONS_PER_GAME = 1
# Don't buy heavy favorites (>78c) — edge is too thin
MAX_ENTRY_PRICE = 0.78
# Don't buy extreme underdogs (<20c) — too risky
MIN_ENTRY_PRICE = 0.20
# Skip toss-ups (45-55c) — model has no edge here
TOSS_UP_LOW = 0.45
TOSS_UP_HIGH = 0.55
From our live trading data: The 20-42c entry range (underdogs) produced the best risk-adjusted returns. Heavy favorites (>70c) lost money despite >60% win rates because the upside per trade is too small to overcome the occasional loss. See the full breakdown on our results page.
Putting it all together into a production loop:
def run_bot(games: list, bankroll: float = 100.0):
"""Main bot loop — scan games, detect edge, execute trades."""
for game in games:
# 1. Get fair value from prediction API
fair = get_fair_value(game["game_id"])
if not fair or fair["status"] != "live":
continue
# 2. Get market price from Polymarket
market = get_polymarket_price(game["home_token_id"])
if market["spread"] > 0.10:
continue # Skip illiquid markets
# 3. Detect edge
edge = detect_edge(fair["home_wp"], market["ask"])
if not edge:
continue
# 4. Size the position
size = kelly_size(fair["home_wp"], market["ask"], bankroll)
if size < 1.0:
continue
# 5. Execute
print(f"TRADE: {fair['home_team']} | "
f"fair={fair['home_wp']:.0%} | "
f"market={market['ask']:.0%} | "
f"edge={edge['edge_c']}c | "
f"size=${size:.2f}")
place_order(
token_id=game["home_token_id"],
side="BUY",
price=market["ask"],
size=size,
)
# Run every 60 seconds
while True:
games = fetch_active_polymarket_games()
run_bot(games)
time.sleep(60)
ZenHodl's prediction API provides calibrated win probabilities for 10 sports, updated in real-time. 335+ live trades with a 66% win rate on moneyline bots.
Start Free 7-Day Trial →Read the API docs · See live results · Free bot-building course
The complete code from this tutorial is available as a starting point. For a more comprehensive walkthrough (6 Jupyter notebooks from data collection to live deployment), check out the free prediction bot course.
Disclosure: This tutorial references ZenHodl, a sports prediction API. Trading on prediction markets involves risk. Past performance of any model or bot does not guarantee future results. Always trade with money you can afford to lose.