How to Build a Polymarket Trading Bot That Finds Mispriced Markets
Get a Python bot reading live Polymarket prices on your laptop tonight and flagging every market that sits 7 points off its estimate after the fee. It runs on public data from any country, paper-trades to a CSV file, and its first run shows you exactly why version one flags almost everything.
>This covers the bot's first scan. Polymarket Profits gives it a smarter brain in Chapter 6, sizes every trade with Kelly in Chapter 8, and lays out three paths from a paper bot to real monthly income.

Hello builders,
Tonight you can have a bot reading live Polymarket prices on your own laptop, reading the order books of 31 busy markets in 7 seconds on my last run. The screenshots that sell Polymarket bots never show the code, and a first bot’s bugs are common ones: it buys the same market twice, or it reads the wrong side of the order book. The Polymarket trading bot Python file below is the one from my book, cut down to a single scan, and it needs no key, no wallet and no account.
Public market data needs no credentials, so it runs from any country, including the US. You need Python 3.11 or newer and one library:
mkdir polybot && cd polybot
python3 -m venv .venv && source .venv/bin/activate
pip install requests
The four jobs in one file
I think of the bot as four people in a room. The Data Watcher pulls prices. The Analyst asks one question per market: is the price far enough from our estimate to pay after the fee? The Trader picks a size, 2% of the paper balance. The Logger writes every decision to trades.csv, because when something goes wrong at 3 AM the log is the only thing that will tell you what happened.
Save this as polybot.py:
import csv, json, os, time
import requests
GAMMA = "https://gamma-api.polymarket.com"
CLOB = "https://clob.polymarket.com"
EDGE_THRESHOLD = 0.07 # 7 points, after the fee
BALANCE = 100.00 # paper money
TRADED_FILE = "traded_markets.json"
def get_markets(limit=50):
params = {"closed": "false", "limit": limit,
"order": "volume24hr", "ascending": "false"}
return requests.get(f"{GAMMA}/markets", params=params,
timeout=30).json()
def get_market_prices(token_id):
book = requests.get(f"{CLOB}/book", params={"token_id": token_id},
timeout=30).json()
bids = [float(b["price"]) for b in book.get("bids", [])]
asks = [float(a["price"]) for a in book.get("asks", [])]
if not bids or not asks:
return None
bid, ask = max(bids), min(asks) # never bids[0] / asks[0]
return (bid + ask) / 2
def taker_fee(rate, p):
return rate * p * (1 - p)
def analyze_market(m, price, baseline=0.50):
if price < 0.05 or price > 0.95:
return None # extreme prices are usually right
rate = float((m.get("feeSchedule") or {}).get("rate") or 0)
edge = baseline - price
net = abs(edge) - taker_fee(rate, price)
if net < EDGE_THRESHOLD:
return None
return ("YES" if edge > 0 else "NO"), net
traded = set(json.load(open(TRADED_FILE))) if os.path.exists(TRADED_FILE) else set()
print(f"Mode: PAPER | Balance: ${BALANCE:.2f} | "
f"Edge threshold: {EDGE_THRESHOLD:.0%} | Max position: $5.00")
signals = scanned = 0
with open("trades.csv", "a", newline="") as f:
log = csv.writer(f)
for m in get_markets():
if json.loads(m["outcomes"])[:1] != ["Yes"]:
continue
scanned += 1
price = get_market_prices(json.loads(m["clobTokenIds"])[0])
signal = price and analyze_market(m, price)
if not signal or m["conditionId"] in traded:
continue
signals += 1
side, net = signal
size = min(5.00, BALANCE * 0.02)
print(f"[PAPER] {side} ${size:.2f} {m['question'][:28]}.. edge {net:.1%}")
log.writerow([time.strftime("%Y-%m-%d %H:%M:%S"), m["question"],
side, round(price, 4), size, round(net, 4), True])
traded.add(m["conditionId"])
json.dump(sorted(traded), open(TRADED_FILE, "w"))
print(f"Scanned {scanned} markets, signals found: {signals}")
Run it with python3 polybot.py. The first line it prints is the setup, then one [PAPER] row per trade it would have made, then a count.
Why max and min matter
That comment in get_market_prices is the bug I see most. When I wrote this I pulled the book for the busiest market, “Will Kylian Mbappé win the 2026 Ballon d’Or?”, straight from Polymarket’s public CLOB. It came back with 55 bids and 104 asks, sorted worst price first:
bids[0] = 0.001 asks[0] = 0.999 spread 0.998
max bid = 0.205 min ask = 0.209 spread 0.004
So the real spread was 0.209 minus 0.205, or 0.4 cents, and the first item in each list would have told you 99.8 cents. Every filter after that point works on the wrong number. Do this math, every time.
The fee is the other number we read per market. Gamma returned a feeSchedule rate of 0.03 on the Ballon d’Or markets and 0 on “Will there be no change in Fed interest rates”, which is why the code reads the rate from each market and never hardcodes it. The taker fee is rate × p × (1 − p), and the edge is measured after it.
Read your first scan

That’s Scan #1 from the book’s test run, which uses the fuller bot from the companion repo (it scans every 30 seconds, so it prints a scan number, and the one-scan file above prints a setup line and a count in its place). Mode is PAPER, so no money moves. The balance is $100.00, the edge threshold is 7% and the cap is $5.00, so 2% of the balance puts every paper trade at $2.00. The first row is NO $2.00 on the Fed no-change market at an edge of 41.5%.
That run scanned 50 markets and found 49 signals. Why would a bot flag 49 out of 50? Our baseline is 0.50 for every market, and the busiest markets sit far from 50 cents (that Fed market was at $0.915), so against a coin flip almost all of them look mispriced. The bot is reporting its own estimate back to you.
When I ran the file above, it scanned the 31 Yes/No markets in the top 50 and logged 14 paper trades at edges from 13.5% to 43.5%. Your counts will differ, because the markets move every hour. The price filter in analyze_market cut 6 more that sat under $0.05 or over $0.95, because a market at $0.01 asking whether the sun explodes tomorrow is priced right. A second run logged 0, since every market it flagged was already in traded_markets.json. That file is what stops a restart from buying the same market twice.
What we have now is the plumbing: real prices, edge after the fee, a size and a log. The estimate is still one line, baseline=0.50, and the book’s next chapter replaces it with baselines from markets you’ve watched resolve. Your bot is dumber than you, but faster.
Now go build something this weekend!
John Cook