{"slug": "we-rebuilt-our-polymarket-clob-api-starter-from-its-own-prompt-3-of-the-top-20", "title": "We rebuilt our Polymarket CLOB API starter from its own prompt: 3 of the top 20 order books had only one side", "summary": "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.", "body_md": "*Originally published on [BotCookery](https://botcookery.com/recipes/polymarket-clob-api-starter/), 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.*\n\nOur 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.\n\nOn 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.\n\nA 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.\n\nThen a Sunday morning of esports, tennis and elections broke the rest of it. Here's what went wrong and how the prompt changed.\n\nInternational 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.\n\nThe 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.\n\n`book` crashed on 3 of the top 20 markets, every cycle\nThe 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:\n\n```\n[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\n[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\n...\n[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\n```\n\nSame three markets, every time:\n\n```\n== dota2-liquid-og-2026-10-25-game1 [0] rc=1\n  File \".../polymarket-clob-starter/cli.py\", line 41, in <module>\nValueError: max() iterable argument is empty\n== dota2-liquid-og-2026-10-25-game1 [1] rc=1\nValueError: min() iterable argument is empty\n```\n\nThese 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.\n\nIf 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.\n\nThe 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.\n\nThe 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:\n\n```\n  open                   two-sided bid 0.787  ask 0.788  spr 0.001  Yes                will-flvio-bolsonaro-win-the-2026-brazilian-presidential-election\n  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\n  resolving (proposed)   one-sided bid -      ask 0.001  spr -      Team Liquid        dota2-liquid-og-2026-10-25-game1\n  ...\n  resolving (disputed)   one-sided bid 0.999  ask -      spr -      Yes                will-russia-and-ukraine-hold-any-diplomatic-meeting-by-october-31-2026\n  resolving (proposed)   one-sided bid 0.999  ask -      spr -      FUT Esports        cs2-fut-tl1-2026-10-25\n[10:14:27 UTC] loop 1: 17 two-sided, 3 one-sided, 0 errors\n```\n\nAfter four loops, the CS2 market finished resolving (it closed at 10:18:20 UTC), and its book disappeared:\n\n```\n  ERROR cs2-fut-tl1-2026-10-25: HTTP 404 from https://clob.polymarket.com/book: {\"error\":\"No orderbook exists for the requested token id\"}\n[10:19:10 UTC] loop 5: 17 two-sided, 2 one-sided, 1 errors\n...\n[10:22:28 UTC] loop 8: 18 two-sided, 2 one-sided, 0 errors\n```\n\nTwo things happen at resolution, and both look like bugs in your code if you don't know about them:\n\n`/book` returns `/markets?slug=` returns an `closed=true`.\nSo 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.\n\nIt'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).\n\nThe 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.\n\nOne 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.\n\nCopy 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.\n\nThe 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.\n\nInternational 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.\n\nOur prompt already said to read `feeSchedule` per market. The run is the reason it now says, in capitals, never to hardcode a category rate.\n\nThe 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.\n\nHere'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:\n\n```\n{\"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\"}\n{\"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\"}\n{\"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\"}\n```\n\nThe 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.\n\nIn 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.\n\nThis is the bit people search for (\"how do I get a Polymarket API key\"), and the starter only explains it, offline:\n\n`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.\n`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.\n\nPaste this follow-up prompt into the same project:\n\n```\nUpdate my Polymarket CLOB starter:\n1. parse_book: either side can be empty (markets whose result is\n   \"proposed\" or \"disputed\" often have only 0.999 bids or 0.001 asks).\n   best_bid/best_ask None, mid/spread None, never max()/min() on an empty\n   list. Add Quote.book_hash and a \"two-sided\"/\"one-sided\"/\"empty\" label.\n   book prints \"no bids\"/\"no asks\" and \"-\" for missing numbers.\n2. get_book: a 404 \"No orderbook exists\" means the market closed (books\n   are deleted at resolution) or the token is wrong; raise PMError saying so.\n3. gamma_market: if /markets?slug= is empty, retry with closed=true and\n   report \"closed at <closedTime> (<umaResolutionStatus>), final prices\";\n   else try /events?slug= and list the event's market slugs.\n4. list_markets: add status from m.resolution.uma_resolution_status and\n   m.state.accepting_orders (\"resolving (proposed)\", \"not accepting\n   orders\", \"open\"). Paper refuses non-open markets.\n5. Take tick_size and min_order_size from the book, never from Gamma, and\n   never show Gamma bestBid/bestAsk/outcomePrices as live prices.\n6. Paper: if the limit doesn't cross, reject with \"would rest on the book;\n   paper mode only simulates orders that fill now\"; record partial fills;\n   fee per filled level, rounded to 5 decimals.\n7. Add `scan [--n 20] [--loops 1] [--interval 60]`: one line per market\n   per loop, a \"[HH:MM:SS UTC] loop N: X two-sided, Y one-sided, Z errors\"\n   summary, flush=True, and an error on one market never stops the loop.\n8. The CLI catches PMError, prints one line and exits 1 (no tracebacks).\nAdd tests for each change. Run them and show me the diff. Still no keys\nand no order placement.\n```\n\nStarting fresh? The full prompt is on the recipe page. After it builds:\n\n```\npython3 -m venv .venv && source .venv/bin/activate\npip install polymarket-client requests python-dotenv pytest eth-account\npython3 -m pytest -q\npython3 cli.py markets\npython3 cli.py book <slug-from-the-list> 0\npython3 cli.py scan --n 20 --loops 5 --interval 60 | tee scan.log\npython3 cli.py paper <slug> 0 <best-ask> 5\npython3 auth_explain.py\n```\n\nThe 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.\n\nThe 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.\n\nThe 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](https://botcookery.com/recipes/polymarket-clob-api-starter/)**. If you'd rather stream the same books than poll them, see our [Polymarket WebSocket starter](https://botcookery.com/recipes/polymarket-websocket-starter/), which ran into the same one-sided books from the other direction.", "url": "https://wpnews.pro/news/we-rebuilt-our-polymarket-clob-api-starter-from-its-own-prompt-3-of-the-top-20", "canonical_source": "https://dev.to/botcookery/we-rebuilt-our-polymarket-clob-api-starter-from-its-own-prompt-3-of-the-top-20-order-books-had-5c7e", "published_at": "2026-10-28 14:02:38+00:00", "updated_at": "2026-10-28 14:11:17.703341+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools"], "entities": ["Polymarket", "Gamma", "UMA", "Dota 2", "CS2", "BotCookery"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/we-rebuilt-our-polymarket-clob-api-starter-from-its-own-prompt-3-of-the-top-20", "markdown": "https://wpnews.pro/news/we-rebuilt-our-polymarket-clob-api-starter-from-its-own-prompt-3-of-the-top-20.md", "text": "https://wpnews.pro/news/we-rebuilt-our-polymarket-clob-api-starter-from-its-own-prompt-3-of-the-top-20.txt", "jsonld": "https://wpnews.pro/news/we-rebuilt-our-polymarket-clob-api-starter-from-its-own-prompt-3-of-the-top-20.jsonld"}}