NEW: Live arbitrage across 10+ prediction markets.Arbitrage →
← Index
APIOct 15, 2026

Polymarket Arbitrage Bot in Python (2026)

Polymarket Arbitrage Bot in Python (2026)

The Short Answer

A Polymarket arbitrage bot in Python looks for a complete set of outcomes that costs less than $1 at the live asks after taker fees: YES plus NO in one binary market, or YES on every outcome of a negative-risk event. Read token IDs from the Gamma API, pull books in batches of up to 500 with POST /books, add the fee C x feeRate x p x (1 - p) to every leg, size the trade from depth, and send each leg as a Fill or Kill order with the official polymarket-client SDK. In our scan of 10,059 sets on 15 October 2026, none cleared a one-cent edge after fees.

Key Takeaways

  • A complete set of outcomes pays exactly $1, so buy one only when its asks plus taker fees total less.
  • YES and NO books mirror each other, so a binary market's two asks almost always sum to $1 plus the spread.
  • Negative-risk events show the gaps: one set of asks summed to $0.976, and fees took it to $1.001.
  • Read each market's fee rate from its feeSchedule: 0.04 to 0.07 as of October 2026, and 0 on geopolitics.
  • Send each leg as an FOK order with polymarket-client 0.13.0, and remember a batch of orders is not atomic.

What arbitrage can a bot find inside Polymarket?

Two kinds, both resting on one fact: some combinations of outcome shares always pay exactly $1.

SetWhat the bot buysWhat the set paysHow you get the $1 back
Binary1 YES and 1 NO of the same market$1 in total, whatever the resultMerge the pair into 1 pUSD at any time, or redeem after resolution
Negative risk1 YES on every outcome of a multi-outcome event$1 from the one outcome that winsRedeem the winning YES after resolution

Polymarket's docs say "a complete Yes/No set is backed by 1 pUSD", and negative-risk markets "never split: exactly one outcome takes the full payout". Buy a full set for less than $1 including fees, and the difference is a potential profit.

Skip "augmented" negative-risk events, flagged negRiskAugmented in Gamma. Outcomes can be added to them later and an unnamed winner resolves to "Other", so YES on every named outcome is not a complete set.

How do you get Polymarket markets and token IDs in Python?

Gamma's GET /events/keyset returns open events with their markets embedded, up to 100 per page, plus a cursor. Token IDs depend on the market's version: Protocol V2 markets list them in positionIds, CTF markets (v1) in clobTokenIds as a JSON string, and Polymarket's docs say to choose by version even when both are filled. Index 0 is YES, index 1 is NO.

import json
import math
import time
import urllib.error
import urllib.parse
import urllib.request

GAMMA = "https://gamma-api.polymarket.com"
CLOB = "https://clob.polymarket.com"
HEADERS = {"User-Agent": "my-arb-scanner/0.1", "Content-Type": "application/json"}


def http_json(url, body=None):
    data = json.dumps(body).encode() if body is not None else None
    for attempt in range(5):
        req = urllib.request.Request(url, data=data, headers=HEADERS)
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                return json.load(resp)
        except urllib.error.HTTPError as err:
            if err.code != 429 and err.code < 500:
                raise  # retry only rate limits and server errors
            time.sleep(float(err.headers.get("Retry-After") or 2 ** attempt))
    raise RuntimeError(f"gave up after 5 tries: {url}")


def open_events(pages=3):
    events, cursor = [], None
    for _ in range(pages):
        url = f"{GAMMA}/events/keyset?closed=false&limit=100&order=volume24hr&ascending=false"
        if cursor:
            url += "&after_cursor=" + urllib.parse.quote(cursor)
        page = http_json(url)
        events += page["events"]
        cursor = page.get("next_cursor")
        if not cursor:
            break
    return events


def asset_ids(market):
    # Protocol V2 markets list positionIds; CTF (v1) markets use clobTokenIds
    if market.get("version") == "v2":
        return market.get("positionIds")
    if market.get("version") == "v1" and market.get("clobTokenIds"):
        return json.loads(market["clobTokenIds"])
    return None


def tradable(market):
    return (market.get("active") and not market.get("closed")
            and market.get("acceptingOrders") and market.get("enableOrderBook")
            and asset_ids(market) is not None)


events = open_events()
markets = [m for e in events for m in e.get("markets", []) if tradable(m)]
print(len(events), "events,", len(markets), "tradable markets")
m = markets[0]
print(m["question"], m["version"], asset_ids(m)[0][:12] + "...", m["feeSchedule"])

Standard library only, nothing to install. On 15 October 2026 it printed 300 events, 9986 tradable markets, then a football market with the fee schedule {'exponent': 1, 'rate': 0.05, 'takerOnly': True, 'rebateRate': 0.15}. Every market we saw was still v1, but asset_ids handles V2. The retry loop honors Retry-After on HTTP 429 and retries server errors, which /books returned once in our tests.

How do you read Polymarket order books in one request?

The CLOB's POST /books returns full books for up to 500 token IDs per request, with tick_size and min_order_size, and is limited to 500 requests per 10 seconds per IP. Asks arrive highest price first, so sort them.

def build_sets(events):
    sets = []
    for event in events:
        live = [m for m in event.get("markets", []) if tradable(m)]
        # (a) binary: buy YES and NO of the same market
        for m in live:
            yes_id, no_id = asset_ids(m)[:2]
            sets.append({"kind": "binary", "title": m["question"],
                         "legs": [(yes_id, m), (no_id, m)]})
        # (b) negative risk: buy YES on every outcome of the event
        complete = len(live) == len(event.get("markets", [])) >= 2
        if event.get("enableNegRisk") and not event.get("negRiskAugmented") and complete:
            sets.append({"kind": "neg-risk", "title": event["title"],
                         "legs": [(asset_ids(m)[0], m) for m in live]})
    return sets


def fetch_books(sets, chunk=500, pause=0.25):
    # keep every leg of a set in the same request, so its books are read together
    batches, batch = [], []
    for s in sets:
        ids = [asset for asset, _ in s["legs"]]
        if len(batch) + len(ids) > chunk:
            batches.append(batch)
            batch = []
        batch += ids
    batches.append(batch)
    books = {}
    for ids in batches:
        for book in http_json(f"{CLOB}/books", [{"token_id": t} for t in ids]):
            books[book["asset_id"]] = book
        time.sleep(pause)  # /books allows 500 requests per 10 seconds
    return books


sets = build_sets(events)
started = time.time()
books = fetch_books(sets)
print(len(sets), "sets,", len(books), "books in", round(time.time() - started), "seconds")
yes_id, no_id = (asset for asset, _ in sets[0]["legs"])
yes_ask = min(float(a["price"]) for a in books[yes_id]["asks"])
no_ask = min(float(a["price"]) for a in books[no_id]["asks"])
print(sets[0]["title"], "| YES ask", yes_ask, "+ NO ask", no_ask, "=", round(yes_ask + no_ask, 4))

It printed 10059 sets, 19934 books in 43 seconds and YES ask 0.15 + NO ask 0.86 = 1.01. Two lessons shaped it. Our first version batched tokens without regard to sets and flagged a live tennis match at $0.95 a set, because its two books were read 17 seconds apart, so every leg of a set now goes in one request. And the two sides mirror: NO's best ask equaled 1 minus YES's best bid in 7,673 of 7,674 binary markets we checked. YES plus NO below $1 means a crossed book, which should match almost as soon as it appears.

How do Polymarket taker fees change the math?

Polymarket charges takers only, at match time: fee = C x feeRate x p x (1 - p), where C is shares and p is price, added on top of a BUY. It peaks at 50 cents and is rounded to 5 decimal places. Rates as of October 2026:

CategoryTaker fee rateFee per share at 50 cents
Crypto0.071.75 cents
Sports, Economics, Culture, Weather, Other0.051.25 cents
Finance, Politics, Mentions, Tech0.041 cent
Geopolitics0None

The sports rate rose from 0.03 to 0.05 on 10 July 2026, so read rates from each market's feesEnabled and feeSchedule in Gamma. Every market we read used an exponent of 1, matching the formula, and the code refuses to price any other curve.

Fees close most gaps. Our closest set was the politics event "Who wins the Senate pending oil below $85 before the midterms?", at a 0.04 rate:

OutcomeBest askTaker fee per share
Oil < $85; R Senate0.0820.0030
Oil < $85; D Senate0.1210.0043
Oil ≥ $85; R Senate0.2610.0077
Oil ≥ $85; D Senate0.5120.0100
Set0.9760.025, for $1.001 in total

A 2.4-cent gap became a 0.1-cent loss. Every other cost is in the Polymarket fees guide.

How do you size the trade from book depth?

The best ask covers only the shares resting there. Walk every leg's ladder together, buying equal shares on each, and stop when one more set would cost more than $1 minus your minimum edge.

def taker_fee(shares, price, market):
    # Polymarket: fee = C x feeRate x p x (1 - p), paid by the taker on top of a BUY
    if not market.get("feesEnabled"):
        return 0.0
    schedule = market.get("feeSchedule") or {}
    if schedule.get("exponent", 1) != 1:
        return math.inf  # unknown fee curve: never trade it
    return shares * float(schedule["rate"]) * price * (1 - price)


def evaluate(s, books, max_shares=1000, min_edge=0.01):
    """Buy the same number of shares on every leg while one more set costs under $1."""
    ladders = []
    for asset, market in s["legs"]:
        book = books.get(asset)
        if not book or not book["asks"] or market.get("secondsDelay"):
            return None  # no book, no asks, or a matching delay on this market
        asks = sorted((float(a["price"]), float(a["size"])) for a in book["asks"])
        ladders.append({"asset": asset, "market": market, "asks": asks, "level": 0,
                        "left": asks[0][1], "spend": 0.0, "fees": 0.0, "max_price": 0.0,
                        "min_size": float(book["min_order_size"])})
    shares = 0.0
    while shares < max_shares and all(l["level"] < len(l["asks"]) for l in ladders):
        prices = [l["asks"][l["level"]][0] for l in ladders]
        unit_cost = sum(p + taker_fee(1, p, l["market"]) for p, l in zip(prices, ladders))
        if unit_cost > 1 - min_edge:
            break
        step = min(min(l["left"] for l in ladders), max_shares - shares)
        for p, l in zip(prices, ladders):
            l["spend"] += step * p
            l["fees"] += taker_fee(step, p, l["market"])
            l["max_price"] = p
            l["left"] -= step
            if l["left"] <= 1e-9:
                l["level"] += 1
                if l["level"] < len(l["asks"]):
                    l["left"] = l["asks"][l["level"]][1]
        shares += step
    if shares == 0 or any(l["spend"] < l["min_size"] for l in ladders):
        return None
    cost = sum(l["spend"] + l["fees"] for l in ladders)
    return {"kind": s["kind"], "title": s["title"], "shares": round(shares, 2),
            "cost": round(cost, 2), "profit": round(shares - cost, 2),
            "legs": [{"asset_id": l["asset"], "amount": math.floor(l["spend"] * 100) / 100,
                      "max_price": l["max_price"]} for l in ladders]}


def top_cost(s, books):
    """Cost of one set at the best asks, fees included."""
    total = 0.0
    for asset, market in s["legs"]:
        book = books.get(asset)
        if not book or not book["asks"]:
            return None
        price = min(float(a["price"]) for a in book["asks"])
        total += price + taker_fee(1, price, market)
    return total


hits = [r for s in sets if (r := evaluate(s, books))]
print(len(hits), "of", len(sets), "sets clear a 1 cent edge after fees")
near = sorted((c, s["kind"], s["title"][:45]) for s in sets if (c := top_cost(s, books)))
for cost, kind, title in near[:3]:
    print(f"closest: {kind} | {title} | {cost:.4f} per set after fees")

Each step buys the smallest quantity left at any leg's current level, keeping share counts equal. It skips markets with a secondsDelay, where matching waits, and legs below the minimum order size, which Polymarket's SDK docs call a minimum USDC notional. Per leg it returns the dollars to spend and max_price, the deepest level priced, which feed the orders below. It printed 0 of 10059 sets clear a 1 cent edge after fees, with the three closest at 1.0010 per set. Allowing delayed markets and a zero edge still found nothing.

How do you run the scan loop without hitting rate limits?

def scan_once():
    sets = build_sets(open_events())
    books = fetch_books(sets)
    hits = [r for s in sets if (r := evaluate(s, books))]
    return sorted(hits, key=lambda r: -r["profit"]), len(sets)


INTERVAL = 60  # seconds between full passes

while True:
    started = time.time()
    hits, checked = scan_once()
    print(time.strftime("%H:%M:%S"), checked, "sets checked,", len(hits), "above the edge")
    for hit in hits[:3]:
        print("  ", hit["kind"], "|", hit["title"][:50], "|", hit["shares"], "sets,",
              "potential profit", hit["profit"])
    time.sleep(max(0, INTERVAL - (time.time() - started)))

Each pass makes 3 Gamma and about 40 /books requests, far below the limits of 500 per 10 seconds on each, with a pause between batches anyway. Both test passes printed 10059 sets checked, 0 above the edge. A pass took 43 seconds, so polling sees each book about once a minute. To react faster, stream shortlisted tokens through the Polymarket WebSocket API and re-run evaluate on each change.

How do you place both legs with the official Python SDK?

Polymarket's unified Python SDK is polymarket-client, imported as polymarket: 0.13.0 on PyPI as of 15 October 2026, for Python 3.11 or newer. It replaces py-clob-client-v2, as the py-clob-client v2 migration guide explains.

import asyncio
import os

from polymarket import AsyncSecureClient


async def execute(client, hit):
    if len(hit["legs"]) > 15:
        return False  # post_orders takes at most 15 signed orders
    orders = [
        await client.create_market_order(
            asset_id=leg["asset_id"],
            side="BUY",
            amount=str(leg["amount"]),        # USD to spend on this leg, before fees
            max_price=str(leg["max_price"]),  # never buy above the level we priced
            order_type="FOK",                 # the whole leg fills, or none of it
        )
        for leg in hit["legs"]
    ]
    responses = await client.post_orders(orders)
    for leg, r in zip(hit["legs"], responses):
        print(leg["asset_id"][:12], r.status if r.ok else f"rejected: {r.code} {r.message}")
    if not all(r.ok and r.status == "matched" for r in responses):
        raise SystemExit("not every leg filled: stop and flatten the open legs")
    return True


async def main(hit):
    async with await AsyncSecureClient.create(
        private_key=os.environ["POLYMARKET_PRIVATE_KEY"],
        wallet=os.environ["POLYMARKET_WALLET_ADDRESS"],
    ) as client:
        await execute(client, hit)

# asyncio.run(main(hits[0]))  # only after you have tested with small sizes

Each leg is a market BUY: amount is pre-fee dollars, fees go on top, and max_price blocks fills above the level the scanner priced. FOK fills the whole order or none of it. FAK keeps partial fills, which leaves uneven legs, and it is the SDK's default, so set order_type yourself. post_orders takes 1 to 15 signed orders that "are processed in parallel", per Polymarket's API reference, so one leg can fill while another is killed. Check every response. The Polymarket order types guide compares FOK, FAK, GTC and GTD.

What are the risks of a Polymarket arbitrage bot?

  • Leg risk. If one FOK leg is killed after another fills, you hold an unhedged position until you sell it.
  • Partial fills. Books move between read and order, so expect fok_not_filled rejections, and the SDK floors share counts, so legs can differ by a fraction of a share.
  • Fees. Rates change, and a bot that caches them misprices the edge.
  • Resolution differences. Augmented events can resolve to an outcome you never held, and disputes delay payouts, which ties up capital in negative-risk sets.
  • Competition. Other bots read the same public books, so a visible gap rarely lasts. Crypto markets also add a 150 ms taker delay, in place since 4 September 2026.

Returns are potential, never certain, and trading involves risk. Start small, with a kill switch.

Where does Predictefy fit?

Inside one venue, the code above is most of the job. Wider gaps tend to sit across venues, where the same event trades on Polymarket, Kalshi and others, and matching those markets is the hard part. The Predictefy API matches markets across 15+ venues, and its arbitrage feed, on the Builder plan ($48.88 a month) and above, labels a row "arbitrage" only when both legs are executable at your size against live books and resolution equivalence is verified. For that build, see how to build a prediction market arbitrage bot, the Polymarket and Kalshi arbitrage bot guide and a worked Kalshi and Polymarket arbitrage example.

Frequently Asked Questions

Does arbitrage still exist inside Polymarket?

Rarely, and briefly. In our scan of 10,059 sets on 15 October 2026, none cleared a one-cent edge after taker fees, and the closest cost $1.001 per set. Binary markets almost never qualify because their YES and NO books mirror each other.

How do you calculate Polymarket's taker fee?

Multiply shares by the market's fee rate, the price, and 1 minus the price. Rates run from 0.04 to 0.07 by category as of October 2026, geopolitics markets are fee-free, and makers pay nothing.

Should an arbitrage bot use FOK or FAK orders?

FOK, for each leg, so no leg ends up half bought. FAK keeps partial fills and leaves uneven legs. Neither makes a batch of legs atomic.

Which Python package should a Polymarket bot use in 2026?

polymarket-client, Polymarket's unified SDK, at 0.13.0 on 15 October 2026, for Python 3.11 or newer. Read-only scanning needs no package, because Gamma and the CLOB's book endpoints are public.

How many Polymarket order books can you fetch at once?

Up to 500 token IDs per POST /books request, limited to 500 requests per 10 seconds per IP as of October 2026. Keep every leg of a set in one request.

What is negative-risk arbitrage on Polymarket?

Buying YES on every outcome of an event where only one can win. Exactly one YES pays $1, so the set is worth buying only below $1 after fees. Skip augmented events, where an unnamed result resolves to Other.