Building a Polymarket Trading Bot: Order Book Monitoring, Slippage Handling, and Position Sizing in Python
Why naive orders lose money on Polymarket
Most people's first Polymarket bot places a market order and calls it done. That works fine on liquid, high-volume markets like major elections. But Polymarket has thousands of long-tail markets with thin order books and wide bid-ask spreads. On those markets, a naive market order can fill at a price 5-10 cents away from the quote you saw a second ago, which for a binary contract priced in cents is a massive effective cost.
Polymarket splits its API into two separate services worth knowing up front: the Gamma API (gamma-api.polymarket.com) for public, read-only market discovery, and the CLOB API (clob.polymarket.com) for order books, prices, and actual trading. Market data reads require no authentication; placing orders does.
Basic bot skeleton: discovering a market and reading the book
import httpx
from py_clob_client.client import ClobClient
from py_clob_client.clob_types import BookParams
# Discover a market via the public Gamma API
resp = httpx.get("https://gamma-api.polymarket.com/markets", params={
"limit": 1,
"active": True,
"order": "volume24hr",
"ascending": False,
})
market = resp.json()[0]
condition_id = market["conditionId"]
clob_token_ids = market["clobTokenIds"]
yes_token_id = clob_token_ids[0]
no_token_id = clob_token_ids[1]
print(f"Market: {market['question']}")
print(f"Yes token: {yes_token_id}")
# Read-only CLOB client - no private key needed just to read the book
client = ClobClient("https://clob.polymarket.com")
book = client.get_order_book(yes_token_id)
mid = client.get_midpoint(yes_token_id)
Implied probability vs. your model's probability
Polymarket prices are already implied probabilities - a Yes price of $0.65 means the market is pricing that outcome at 65%. Your edge, if any, comes from your model disagreeing with that price:
def calculate_edge(model_probability: float, market_price: float) -> float:
"""Positive edge means your model thinks the outcome is more likely than the market is pricing it."""
return model_probability - market_price
model_prob = 0.72 # your model's estimate
market_price = float(mid) # current market midpoint
edge = calculate_edge(model_prob, market_price)
MIN_EDGE_THRESHOLD = 0.05 # don't trade on noise-level edges
should_enter = abs(edge) > MIN_EDGE_THRESHOLD
Risk logic: position sizing based on edge %
A fractional-Kelly approach scaled down for prediction-market-specific risk (binary payout, model uncertainty) works better than full Kelly, which tends to oversize on overconfident models:
def position_size(
bankroll: float,
edge: float,
market_price: float,
kelly_fraction: float = 0.25,
max_position_pct: float = 0.05
) -> float:
"""Fractional Kelly sizing, capped by a hard max-exposure-per-market limit."""
if market_price <= 0 or market_price >= 1:
return 0.0
# Simplified binary Kelly: edge / odds against you
odds = (1 - market_price) / market_price
kelly_pct = edge / odds if odds > 0 else 0
sized_pct = max(0, kelly_pct * kelly_fraction)
capped_pct = min(sized_pct, max_position_pct) # hard cap per market
return bankroll * capped_pct
The max_position_pct cap matters more than the Kelly fraction itself - it's your hard stop against a single mispriced model wrecking the account.
Handling partial fills and slippage
Thin order books mean your order may only partially fill at your target price. Rather than chasing the fill with a market order (which is how slippage compounds), check remaining depth before adding size:
def get_fillable_size(book, target_price: float, side: str = "BUY") -> float:
"""Sum size available at or better than target_price."""
levels = book.asks if side == "BUY" else book.bids
fillable = 0.0
for level in levels:
price = float(level.price)
if (side == "BUY" and price <= target_price) or (side == "SELL" and price >= target_price):
fillable += float(level.size)
else:
break
return fillable
fillable = get_fillable_size(book, target_price=market_price + 0.01)
order_size = min(position_size(bankroll=1000, edge=edge, market_price=market_price), fillable)
if order_size < MIN_ORDER_SIZE:
# not enough liquidity at an acceptable price
# skip or use a limit order and wait
pass
If fillable liquidity is far below your intended size, the honest move is a resting limit order rather than forcing a market order through a thin book.
What I'd do differently
A few lessons that only became obvious after running this against real markets:
Poll less, cache more. Hammering the Gamma API on a tight loop for markets that update every few minutes wastes rate limit budget you'll need when a market is actually moving fast. Poll frequency should scale with time-to-resolution and recent volume, not run on a flat interval.
Log every rejected trade, not just executed ones. The trades your bot decided not to make (edge below threshold, insufficient liquidity) are the dataset that tells you whether your edge threshold is even calibrated correctly.
Treat correlated markets as one position, not many. If your model has edge on "Fed cuts in March" and "Fed cuts in Q1," those aren't independent bets - size them as a combined exposure or you'll be more leveraged to one underlying event than your risk logic assumes.
The order book snapshot is already stale by the time you act on it. For anything beyond casual trading, a WebSocket feed (
wss://ws-subscriptions-clob.polymarket.com/ws/market) beats polling - the latency difference is the gap between getting filled at your price and getting picked off.
Originally explored in more depth here: Github full description
Comments
No comments yet. Start the discussion.