cd /news/ai-tools/we-rebuilt-our-polymarket-clob-api-s… · home › topics › ai-tools › article
[ARTICLE · art-159583] src=dev.to ↗ pub= topic=ai-tools verified=true sentiment=· neutral

We rebuilt our Polymarket CLOB API starter from its own prompt: 3 of the top 20 order books had only one side

A developer rebuilt a Polymarket CLOB API starter prompt and stress-tested it against the top 20 markets by 24-hour volume, finding that 3 of the 20 order books were one-sided and crashed the generated code with empty max()/min() errors on every cycle. The failing markets—a Dota 2 match, a CS2 match, and a Russia–Ukraine diplomacy market—were still active and accepting orders, with bids at 0.999 and no asks or asks at 0.001 and no bids. The revised prompt makes best_bid, best_ask, mid and spread return None when a side is empty instead of calling max()/min() on an empty list.

by read10 min views1 publishedOct 28, 2026

Originally published on BotCookery, free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every output below is from real runs on public Polymarket data on Oct 25, 2026. The code holds no private key and can't place an order, and nothing here is financial advice.

Our Polymarket CLOB API starter is a prompt. Paste it into your AI coding tool in an empty folder and you get a small Python toolkit for the international Polymarket order book: Gamma market lookups, the CLOB /book read the right way round, per-market fees, an offline walk-through of L1 and L2 auth, and a paper broker that fills against the live book. No wallet, no API key, no order code.

On Oct 25 we tested it the only way that counts. We pasted the published prompt into a clean folder, let it build, and ran book on each of the top 20 markets by 24-hour volume, every ~50 seconds for about 6 minutes, plus a paper round trip and the auth demo.

A lot of it held. The sorted book's mid matched both the SDK's PublicClient().get_midpoint() and raw /midpoint on all 5 markets we checked. The fee formula matched Polymarket's fee docs. The L1 typed data and L2 HMAC matched the SDK's own helpers on throwaway keys. The paper ledger rebuilt correctly across separate processes.

Then a Sunday morning of esports, tennis and elections broke the rest of it. Here's what went wrong and how the prompt changed.

International Polymarket splits its public API across three hosts. Gamma (https://gamma-api.polymarket.com) has markets and events: questions, slugs, and clobTokenIds, which are JSON-encoded strings you have to json.loads. The CLOB ( https://clob.polymarket.com) has order books, midpoints and, for authenticated users, orders. The Data API (https://data-api.polymarket.com) has trades, positions and price history. Reads need no key.

The classic gotcha is still there: raw /book lists bids ascending and asks descending, so the best price on each side is the last element. Our original prompt already said to sort. That part was fine.

book crashed on 3 of the top 20 markets, every cycle The original prompt said "best_bid = max bid price, best_ask = min ask price". Simple enough, until a side is empty. Here's the harness log from the original build, 8 cycles over the top 20 markets:

[10:12:07 UTC] cycle 1: book ok 17, crashed 3: dota2-liquid-og-2026-10-25-game1 will-russia-and-ukraine-hold-any-diplomatic-meeting-by-october-31-2026 cs2-fut-tl1-2026-10-25
[10:12:57 UTC] cycle 2: book ok 17, crashed 3: dota2-liquid-og-2026-10-25-game1 will-russia-and-ukraine-hold-any-diplomatic-meeting-by-october-31-2026 cs2-fut-tl1-2026-10-25
...
[10:17:53 UTC] cycle 8: book ok 17, crashed 3: dota2-liquid-og-2026-10-25-game1 will-russia-and-ukraine-hold-any-diplomatic-meeting-by-october-31-2026 cs2-fut-tl1-2026-10-25

Same three markets, every time:

== dota2-liquid-og-2026-10-25-game1 [0] rc=1
  File ".../polymarket-clob-starter/cli.py", line 41, in <module>
ValueError: max() iterable argument is empty
== dota2-liquid-og-2026-10-25-game1 [1] rc=1
ValueError: min() iterable argument is empty

These weren't dead markets. A Dota 2 game and a CS2 match had a result proposed through UMA, and a Russia–Ukraine diplomacy market had a result disputed. All three were still active and accepting orders, and all three sat in the top 20 by 24h volume. Their books were one-sided: 0.999 bids with nothing on the ask, or 0.001 asks with nothing on the bid, for hundreds of thousands of shares.

If you're writing a scanner, a dashboard or a bot loop, this is the case that kills it: the busiest markets of the morning are exactly the ones that just finished.

The fix in the prompt: either side can be empty, so best_bid/ best_ask become None, and so do mid and spread. Never call max()/ min() on an empty list. book prints no bids / no asks. markets now reads uma_resolution_status and accepting_orders and labels each market, and the paper broker refuses anything that isn't open.

The rebuilt version has a scan command: one line per market per loop, a summary line, and an error on one market never stops the loop. We ran it for 8 loops on the top 20 (06:14–06:22 ET). Here's loop 1, trimmed:

  open                   two-sided bid 0.787  ask 0.788  spr 0.001  Yes                will-flvio-bolsonaro-win-the-2026-brazilian-presidential-election
  open                   two-sided bid 0.21   ask 0.22   spr 0.01   Yes                will-luiz-incio-lula-da-silva-win-the-2026-brazilian-presidential-election
  resolving (proposed)   one-sided bid -      ask 0.001  spr -      Team Liquid        dota2-liquid-og-2026-10-25-game1
  ...
  resolving (disputed)   one-sided bid 0.999  ask -      spr -      Yes                will-russia-and-ukraine-hold-any-diplomatic-meeting-by-october-31-2026
  resolving (proposed)   one-sided bid 0.999  ask -      spr -      FUT Esports        cs2-fut-tl1-2026-10-25
[10:14:27 UTC] loop 1: 17 two-sided, 3 one-sided, 0 errors

After four loops, the CS2 market finished resolving (it closed at 10:18:20 UTC), and its book disappeared:

  ERROR cs2-fut-tl1-2026-10-25: HTTP 404 from https://clob.polymarket.com/book: {"error":"No orderbook exists for the requested token id"}
[10:19:10 UTC] loop 5: 17 two-sided, 2 one-sided, 1 errors
...
[10:22:28 UTC] loop 8: 18 two-sided, 2 one-sided, 0 errors

Two things happen at resolution, and both look like bugs in your code if you don't know about them:

/book returns /markets?slug= returns an closed=true. So between two scan loops, a lookup by slug went from "one-sided" to "no such market". The rebuilt prompt treats the 404 as "closed, or wrong token", retries Gamma with closed=true, and reports the close time, umaResolutionStatus and final prices instead of a traceback. The 8-loop scan logged the 404 and kept going: 0 crashes.

It's tempting to read bestBid, bestAsk or outcomePrices straight off Gamma, since it's already in the market object. During a live CS2 match, Gamma said 0.79/0.80 while the book and /midpoint said 0.85/0.86. Gamma's orderPriceMinTickSize also said 0.01 on a market whose book had already moved to a 0.001 tick (tick size tightens as prices approach 0 or 1).

The prompt now takes prices, tick size and minimum order size from the book only, and never shows Gamma's numbers as live prices. If your "price check" compares your book against Gamma, you'll see phantom mismatches whenever something is actually happening.

One more API quirk we checked again: GET /price?side=BUY returned the best bid on Oct 22 and again on Oct 25, while the side naming in the docs reads the other way. We don't use /price for the top of book at all. Derive bid and ask from the sorted book.

Copy a slug out of a polymarket.com/event/... URL and you often have an event slug, not a market slug. book brazil-presidential-election failed with "no market with slug", even though that event has 32 markets.

The rebuilt gamma_market() checks closed markets first (many single-match events share their slug with the main market), then tries /events?slug=, and if it finds an event, lists its first 10 market slugs so you can pick one.

International Polymarket's taker fee is shares × rate × p × (1 − p), rounded to 5 decimals in USDC, with the rate in each market's own feeSchedule. The docs have a category table, so it's tempting to hardcode it. Live rates that morning: 0 on the Brazil election and Fed-rate markets, 0.05 on tennis and esports, and 0.03 on the Ballon d'Or and an NFL total. The docs' table has no 0.03 category.

Our prompt already said to read feeSchedule per market. The run is the reason it now says, in capitals, never to hardcode a category rate.

The paper broker only simulates taker orders: a BUY walks the live asks up to your limit, a SELL walks the bids down. In the original build, a limit below the ask came back as "no liquidity". That's wrong. There was plenty of liquidity; your order just wouldn't fill now. It now says the order "would rest on the book; paper mode only simulates orders that fill now", and records partial fills as partial instead of pretending they filled in full.

Here's the paper ledger from the rebuilt version: a round trip on the Rybakina vs Fernandez tennis market (fee rate 0.05), with each command in a separate process so the ledger rebuild gets exercised. Token IDs trimmed:

{"slug": "wta-rybakin-fernand-2026-10-25", "outcome": "Elena Rybakina", "side": "BUY", "limit": 0.94, "requested": 5.0, "shares": 5.0, "partial": false, "avg_price": 0.94, "fee": 0.0141, "fee_rate": 0.05, "realized": 0.0, "book_hash": "51d853fc36f03b3aebad567dc2f345a1cc1f5947"}
{"slug": "wta-rybakin-fernand-2026-10-25", "outcome": "Elena Rybakina", "side": "BUY", "limit": 0.94, "requested": 5.0, "shares": 5.0, "partial": false, "avg_price": 0.94, "fee": 0.0141, "fee_rate": 0.05, "realized": 0.0, "book_hash": "37f85fb8ac9934546d742510c8bb7be122dfd48b"}
{"slug": "wta-rybakin-fernand-2026-10-25", "outcome": "Elena Rybakina", "side": "SELL", "limit": 0.93, "requested": 5.0, "shares": 5.0, "partial": false, "avg_price": 0.94, "fee": 0.0141, "fee_rate": 0.05, "realized": -0.0282, "book_hash": "388d4952d707caa33d13768bf663b8c1e577b782"}

The fee checks out: 5 × 0.05 × 0.94 × 0.06 = 0.0141. The sell had a 0.93 limit but filled at 0.94, because the best bid moved up a tick (new book hash) in the second between the two calls. And the realized −$0.0282 is nothing but the two taker fees on an unchanged price. That's the point of paper mode: a round trip that "breaks even" on price still loses money.

In between those fills, the broker refused a non-crossing limit ("would rest on the book"), an order over the $10 per-order cap, a sell larger than the paper position (no shorting), an order on a resolving market, and every order while a KILL file existed.

This is the bit people search for ("how do I get a Polymarket API key"), and the starter only explains it, offline:

ClobAuthDomain, version 1, chain 137) proving you control a wallet. It's used once, to create or derive API credentials: key, secret, passphrase.timestamp + METHOD + path + body, keyed with the urlsafe-base64- POLY_ADDRESS, POLY_SIGNATURE, POLY_TIMESTAMP, POLY_API_KEY and POLY_PASSPHRASE headers on every trading call. auth_explain.py builds both with a key made in memory by eth_account.Account.create() and obviously fake L2 credentials, and the tests check both against the SDK's own helpers. Nothing is sent. No real key ever touches the project.

Paste this follow-up prompt into the same project:

Update my Polymarket CLOB starter:
1. parse_book: either side can be empty (markets whose result is
   "proposed" or "disputed" often have only 0.999 bids or 0.001 asks).
   best_bid/best_ask None, mid/spread None, never max()/min() on an empty
   list. Add Quote.book_hash and a "two-sided"/"one-sided"/"empty" label.
   book prints "no bids"/"no asks" and "-" for missing numbers.
2. get_book: a 404 "No orderbook exists" means the market closed (books
   are deleted at resolution) or the token is wrong; raise PMError saying so.
3. gamma_market: if /markets?slug= is empty, retry with closed=true and
   report "closed at <closedTime> (<umaResolutionStatus>), final prices";
   else try /events?slug= and list the event's market slugs.
4. list_markets: add status from m.resolution.uma_resolution_status and
   m.state.accepting_orders ("resolving (proposed)", "not accepting
   orders", "open"). Paper refuses non-open markets.
5. Take tick_size and min_order_size from the book, never from Gamma, and
   never show Gamma bestBid/bestAsk/outcomePrices as live prices.
6. Paper: if the limit doesn't cross, reject with "would rest on the book;
   paper mode only simulates orders that fill now"; record partial fills;
   fee per filled level, rounded to 5 decimals.
7. Add `scan [--n 20] [--loops 1] [--interval 60]`: one line per market
   per loop, a "[HH:MM:SS UTC] loop N: X two-sided, Y one-sided, Z errors"
   summary, flush=True, and an error on one market never stops the loop.
8. The CLI catches PMError, prints one line and exits 1 (no tracebacks).
Add tests for each change. Run them and show me the diff. Still no keys
and no order placement.

Starting fresh? The full prompt is on the recipe page. After it builds:

python3 -m venv .venv && source .venv/bin/activate
pip install polymarket-client requests python-dotenv pytest eth-account
python3 -m pytest -q
python3 cli.py markets
python3 cli.py book <slug-from-the-list> 0
python3 cli.py scan --n 20 --loops 5 --interval 60 | tee scan.log
python3 cli.py paper <slug> 0 <best-ask> 5
python3 auth_explain.py

The page's verified line, word for word: Verified working on live data in read-only and paper mode, Oct 25, 2026. Its stated limit: public market data and paper fills only, including an 8-minute scan of the top 20 markets. L1/L2 signing was checked offline against the SDK; no API key was created and no order was sent.

The toolkit has no private key and no order placement, and a test greps the codebase to keep it that way. It prints books, fees and paper fills. It doesn't rank, score or suggest anything. If you ever take something like this live, use a dedicated hot wallet with a small balance, keep the API credentials as carefully as the key, and check Polymarket's geoblock endpoint from the machine that will trade.

The full recipe, with the complete prompt, step-by-step setup, the FAQ and links to every Polymarket doc we checked, is at botcookery.com/recipes/polymarket-clob-api-starter. If you'd rather stream the same books than poll them, see our Polymarket WebSocket starter, which ran into the same one-sided books from the other direction.

── more in #ai-tools 4 stories · sorted by recency
── more on @polymarket 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/we-rebuilt-our-polym…] indexed:0 read:10min 2026-10-28 · —