All articles
Polymarket·June 17, 2026·5 min read

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 project
#polymarket#market-making#python#clob#prediction-markets#trading-bots#defi