Build a Polymarket Market-Making Bot in Python (2025)
End-to-end tutorial for building an automated market maker on Polymarket using the CLOB API: quoting both sides of a binary market, managing inventory, adjusting spreads on news events, and tracking P&L in USDC.
Polymarket's Central Limit Order Book gives you a real REST+WebSocket API, genuine maker rebates (currently ~0.5 bps on filled notional), and a binary payoff structure that makes inventory math straightforward. The catch is that the markets are thin enough that a naive spread gets walked through in seconds, and wide enough that a slow bot leaves money on the table. Here is how to build one that doesn't do either.
How the CLOB API Works
Every market is a pair of YES/NO token addresses on Polygon. You authenticate with an EIP-712 signature derived from your wallet key, and all orders are placed via POST /order. The response includes a orderID you track for fills. Cancels are DELETE /order/{orderID}.
Before quoting, pull the current book:
import requests, time
BASE = "https://clob.polymarket.com"
def get_book(token_id: str) -> dict:
r = requests.get(f"{BASE}/book", params={"token_id": token_id}, timeout=5)
r.raise_for_status()
return r.json()
The response gives you bids and asks as lists of {price, size} dicts. Prices are in USDC cents (0–100). A YES token at 0.62 means the market implies 62% probability. Your job as a market maker is to sit just inside that midpoint on both sides and collect the spread.
Quoting Both Sides
Start with a simple symmetric quote around the midpoint. The key parameters to tune are half-spread (how far each side sits from mid) and order size (your risk per fill).
def compute_quotes(mid: float, half_spread: float, size: float):
bid = round(mid - half_spread, 2)
ask = round(mid + half_spread, 2)
# Clamp to valid range
bid = max(0.01, min(bid, 0.99))
ask = max(0.01, min(ask, 0.99))
return bid, ask, size
# Example: mid = 0.62, half_spread = 0.015, size = 50 USDC
bid, ask, size = compute_quotes(0.62, 0.015, 50.0)
# bid=0.605, ask=0.635
On a liquid market you might narrow to 1 bp; on a thinly traded market you need 2–3 cents or you'll be filling against informed flow. The rebate on Polymarket's CLOB means if you're posting passively you net a small credit per fill, but that doesn't cover the adverse-selection cost if your mid estimate is stale.
Inventory Management
Binary markets create a natural inventory problem. If you keep filling YES bids, you accumulate YES tokens and your effective cost basis drifts. Track position in both tokens separately and skew your quotes to lean against the accumulation:
class InventoryManager:
def __init__(self, max_pos: float = 200.0):
self.yes_pos = 0.0 # USDC notional
self.no_pos = 0.0
self.max_pos = max_pos
def skew(self) -> float:
"""Returns a signed adjustment to push mid toward flattening inventory."""
net = self.yes_pos - self.no_pos
return -0.005 * (net / self.max_pos) # ±0.5 cent max skew
def fill(self, side: str, notional: float):
if side == "YES":
self.yes_pos += notional
else:
self.no_pos += notional
Apply the skew before computing quotes: adjusted_mid = raw_mid + inv.skew(). This nudges your bid down and ask up on the accumulated side, naturally rebalancing without forcing a market order.
Hard limits matter. When yes_pos or no_pos crosses max_pos, cancel all orders on that side and stop quoting it until the fill unwinds. You are not a liquidity provider at that point; you are a directional holder, and you should manage that separately. The bot automation services we run in production enforce this at the order-submission layer, not in application logic, so a crash can't bypass the check.
Adjusting Spreads on News Events
The biggest P&L killer in prediction-market making is quoting a tight spread into a news event — a court ruling, a debate, an election result — and getting picked off on one side while your cancel is in flight. The solution is a volatility circuit breaker.
Monitor for sudden mid moves:
class VolCircuitBreaker:
def __init__(self, window: int = 10, threshold: float = 0.04):
self.mids = []
self.window = window
self.threshold = threshold # 4 cent move triggers widening
def update(self, mid: float) -> float:
self.mids.append(mid)
if len(self.mids) > self.window:
self.mids.pop(0)
if len(self.mids) < 2:
return 1.0
move = abs(self.mids[-1] - self.mids[0])
return max(1.0, move / 0.01) # multiplier: 1x normal, up to 4x on big moves
def spread_mult(self) -> float:
return self.update(self.mids[-1]) if self.mids else 1.0
Multiply your half_spread by this factor every quote cycle. A 4-cent move over ten ticks (roughly 10 seconds at 1-second polling) widens you to 4x normal — you stay in the market but with a spread that compensates for the elevated uncertainty.
Tracking P&L in USDC
Unlike perps, Polymarket P&L is not mark-to-market until settlement. You need to track realized (locked-in fills) and unrealized (current mid value of open inventory) separately:
def pnl_snapshot(fills: list[dict], inv: InventoryManager, mid: float) -> dict:
realized = sum(f["pnl"] for f in fills)
# Unrealized: value tokens at mid, net against cost basis
yes_val = inv.yes_pos * mid
no_val = inv.no_pos * (1 - mid)
cost = inv.yes_cost + inv.no_cost # track separately in fill()
unrealized = (yes_val + no_val) - cost
return {"realized": realized, "unrealized": unrealized, "total": realized + unrealized}
Log this every minute. The ratio of realized / unrealized tells you whether you're actually collecting spread or just sitting on inventory risk. If unrealized keeps growing relative to realized, you're accumulating too fast — tighten the max_pos limit or widen the spread.
Quote Loop and Rate Limits
The CLOB allows roughly 10 order mutations per second per API key. A tight loop that cancels-and-replaces on every tick will hit this. Use a dead-band: only re-quote if the new price differs from the standing order by more than 0.5 cents, or if a fill has changed your inventory skew by more than 5 USDC.
DEAD_BAND = 0.005 # 0.5 cent
def needs_requote(current_bid, current_ask, new_bid, new_ask) -> bool:
return abs(new_bid - current_bid) > DEAD_BAND or \
abs(new_ask - current_ask) > DEAD_BAND
Run the loop at 1-second intervals via asyncio with a separate WebSocket coroutine consuming the fill stream. Fill notifications are near-real-time; use them to update inventory immediately rather than waiting for the next REST poll.
If you want a production-grade version of this — with gas-optimized on-chain settlement, robust error handling, and live monitoring dashboards — reach out through our contact page. We build and operate these systems end to end.
Need a bot like this built?
We design, build and run trading bots on Solana, Hyperliquid and Polymarket.
Start a projectMore from the blog
Public vs Paid RPC: How to Run a Fair Benchmark
A fair comparison of public and paid RPC endpoints must account for quotas, workload, freshness and support guarantees.
Read articleRPC Benchmark Percentiles Explained: p50, p95 and p99
Why averages hide the latency spikes that break trading bots, wallets and production blockchain applications.
Read articleHyperliquid REST vs WebSocket Benchmark: What to Measure
A benchmark plan for Hyperliquid market data that separates request latency from streaming freshness and recovery.
Read article