DEV Community

BotCookery
BotCookery

Posted on Originally published at botcookery.com

We rebuilt our Polymarket WebSocket starter from its own prompt: 342 false mismatches and a hidden #1 market

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 streamer is read-only, and nothing here is financial advice.

Our Polymarket WebSocket starter is a prompt: paste it into your AI coding tool in an empty folder, and you get a small read-only Python streamer for Polymarket's public CLOB market channel. It subscribes to the outcomes you pick, keeps a local order book for each one, checks that book against the server, audits it against REST and survives dropped connections. It holds no key and has no order code.

We first published it on Oct 23. On Oct 25 we tested it the only way that counts: we pasted our own prompt into a clean folder, let it build, and streamed the top 5 markets by 24-hour volume three times for 150 seconds each (13:55–14:03 ET), with a forced reconnect and REST audits every 40 seconds.

The plumbing held. Subscribing, the heartbeat (pongs matched pings), list and object frames, aggregate sizes and the reconnect all worked. The part that's supposed to prove the book is correct did not. Here's what went wrong and how the prompt changed.

What the market channel sends

The short version, for anyone who hasn't used it:

  • URL wss://ws-subscriptions-clob.polymarket.com/ws/market, no auth. Subscribe with {"assets_ids":[...],"type":"market"}, using the outcome token IDs from Gamma's clobTokenIds.
  • Send the text frame PING every 10 seconds. The server answers with the text PONG. Don't json.loads it.
  • book is a full snapshot (sent on subscribe and after trades). A price_change entry carries the new aggregate size at a price, not a delta, and "0" removes the level. Each entry also carries the server's best_bid and best_ask.
  • The first dump arrives as a JSON list, later frames as single objects.

That best_bid/best_ask on every change is a gift: your bot can check its own book against the server on every update. Our streamer did exactly that, resyncing after three mismatches in a row. That check is where things went wrong.

1. One-sided books failed every check

When a side of the book is empty, the server doesn't leave best_ask blank. It sends "1". An empty bid side comes through as best_bid "0".

The #2 market by volume that afternoon was a finished esports match sitting at 0.999 with no asks at all. Our local book correctly said "no ask". The server said "1". Every update on that market counted as a mismatch.

The fix is one line of intent: read best_bid "0" and best_ask "1" as an empty side before comparing.

2. A single sweep looked like a broken book

A taker selling through three bid levels doesn't arrive as one message. It arrives as three price_change messages with the same timestamp. Each one already carries the final, post-match best bid, while the level removals trickle in one message at a time.

So after message one, the server says the best bid is three levels down, and the local book still has two of those levels. The book isn't crossed in between, so our "skip when crossed" rule didn't help. Checking after every message counted three mismatches and forced a reconnect of all 10 books.

This is the first 150-second run of the original build:

=== cycle 1 13:55:47
Public Polymarket market data, read-only. Not advice.
streaming 10 outcomes
TRADE lol-hle1-ig1-2026-10-25 [Invictus Gaming] SELL 15 @ 0.999
disconnected (resync: 3 server mismatches)
disconnected (resync: 3 server mismatches)
...
stats: frames=1517 events={'book': 222, 'best_bid_ask': 114, 'price_change': 1092,
  'last_trade_price': 44, 'new_market': 151} pings=21 pongs=20 reconnects=14
  server_checks=2184 server_mismatches=122 crossed_skipped=0 audit_ok=30 audit_diff=0
Enter fullscreen mode Exit fullscreen mode

Fourteen reconnects in 150 seconds, one of them deliberate. Across the three runs, the two bugs together caused 342 mismatches in about 8,400 checks and 37 needless resyncs in 7.5 minutes, on top of the 3 forced reconnects.

The fix: check per batch, not per message. For each asset, remember the timestamp and server best prices of the last applied entry. Settle (compare) when a price_change for that asset arrives with a different timestamp, after 1 second of silence, and at exit. Replaying the logged events with the new rule (sentinels read as empty, one check per asset per timestamp) gave 0 mismatches in 7,266 checks.

3. --top hid the busiest markets

The Oct 23 prompt filtered Gamma's market list with end_date_min, to skip markets that had already ended. Reasonable, except sports markets use the kickoff time as endDate. The filter dropped every game in progress.

At 2 p.m. ET that was 5 of the top 11 markets, including #1, Browns–Titans, with $3.9M in 24-hour volume. A streamer meant to watch the busiest books was hiding them.

The filter is gone. Instead, --top skips markets that are effectively decided: Gamma's best bid or best ask is missing, at or below 0.01, or at or above 0.99. That also catches the finished esports match from bug 1:

skip lol-hle1-ig1-2026-10-25: decided (bid None ask 0.001)
streaming 10 outcomes
Enter fullscreen mode Exit fullscreen mode

4. The audit froze the book it was auditing

Every 40 seconds the streamer compares each local book with the REST /book endpoint. If they differed, the original version waited 2 seconds and re-checked. The wait was a sleep inside the receive loop, so while it slept the local book stopped updating and REST moved on without it.

With the bugs above fixed and live games in the mix, 13 of 90 audits reported DIFF, while the server check on the same books found 0 mismatches. From the intermediate build, during an in-play EPL match:

audit DIFF epl-sun-lee-2026-10-25-sun [Yes] rest 0.13/0.14 local 0.14/0.2
audit DIFF epl-sun-lee-2026-10-25-sun [No] rest 0.86/0.87 local 0.8/0.86
Enter fullscreen mode Exit fullscreen mode

In a separate 70-second test, all 5 differences had a REST timestamp newer than the last event the local book had applied. The book wasn't wrong. It was behind, because we'd stopped feeding it.

The fix uses a field that was there all along: the REST book has a timestamp on the same millisecond clock as the socket events, and the local book now stores the timestamp of every event it applies. Re-checks are scheduled from the main loop instead of slept. A difference counts as DIFF only when the local book has already seen everything REST had and still disagrees. If REST stays ahead, the audit reports MOVED:

audit MOVED will-flvio-bolsonaro-win-the-2026-brazilian-presidential-election [No]
  (REST stayed ahead: rest 0.206/0.21 @1792952546288 local 0.206/0.209 @1792952546012)
Enter fullscreen mode Exit fullscreen mode

That's not an error: the book changed faster than one REST call.

The rebuilt version

Fresh folder, fixed prompt, three runs of the same command (14:19–14:26 ET), with an NFL game and two EPL markets in play:

$ python3 pmws.py stream --top 5 --duration 150 --print-every 30 --audit-every 40 --drop-after 60 --custom --log events.jsonl
Public Polymarket market data, read-only. Not advice.
skip lol-hle1-ig1-2026-10-25: decided (bid None ask 0.001)
streaming 10 outcomes
TRADE epl-sun-lee-2026-10-25-sun [No] BUY 10 @ 0.91
TRADE epl-sun-lee-2026-10-25-sun [Yes] BUY 125 @ 0.11
...
  nfl-cle-ten-2026-10-25                   Browns       bid 0.54   ask 0.55   spr 0.01   lv 43/38 last -      upd 0s
  nfl-cle-ten-2026-10-25                   Titans       bid 0.45   ask 0.46   spr 0.01   lv 38/43 last -      upd 0s
  epl-sun-lee-2026-10-25-sun               Yes          bid 0.07   ask 0.08   spr 0.01   lv 7/41 last 0.09   upd 0s
  epl-sun-lee-2026-10-25-sun               No           bid 0.92   ask 0.93   spr 0.01   lv 41/7 last 0.92   upd 0s
  will-flvio-bolsonaro-win-the-2026-brazil Yes          bid 0.796  ask 0.799  spr 0.003  lv 137/166 last 0.796  upd 2s
...
disconnected (forced drop after 60.0s)
...
stats: frames=9875 events={'book': 210, 'price_change': 8724, 'best_bid_ask': 350,
  'last_trade_price': 95, 'new_market': 499} pings=15 pongs=15 reconnects=1
  server_checks=15194 server_mismatches=0 crossed_skipped=0 audit_ok=30 audit_moved=0
  audit_diff=0 dropped_before_snapshot=0 unknown=0
Enter fullscreen mode Exit fullscreen mode

Across the three runs: 34,167 price_change events, 54,326 server checks, 0 mismatches. 3 reconnects, all of them the forced ones, and 258 trades. The REST audits came out 89 OK, 1 MOVED, 0 DIFF. One EPL match ended during the runs: both of its books went one-sided at 0.999 and tick_size_change events arrived, without a single mismatch. The 18 offline tests pass (fake socket, fake clock, no network).

Smaller things worth knowing

  • new_market and market_resolved are global. With custom_feature_enabled: true you get them for every market on the exchange, not just the ones you subscribed to (about 90 a minute on Oct 25). Look at the new_market: 499 in the stats line above for a 10-outcome subscription. Count and log them; don't print them.
  • REST books are sorted the "wrong" way for [0]. Bids come ascending and asks descending, so best bid is the max price and best ask is the min. Never take element [0].
  • Normalise price keys. "0.5" and "0.50" are the same level. Key the book by a Decimal-normalised string.
  • One connection is enough. Polymarket's changelog says the 100-token limit on the market channel was removed on May 28, 2025.

The prompt

Paste this into your AI coding tool in an empty folder. Approve the file plan before it writes code, check that it contains no order or key code, and run python3 -m pytest -q before touching the network.

You are building a READ-ONLY Python streamer for the international Polymarket
CLOB public market WebSocket. It keeps a live local order book per outcome,
checks that book against the server, and survives disconnects. It never
holds a key, never signs, never connects to the authenticated user channel
and never places, cancels or simulates real orders. Use only what is
documented at https://docs.polymarket.com (Market Data > Real-Time Data;
API Reference > WebSocket > Market Channel; Prices and Order Books; Rate
Limits). If something isn't documented, stop and ask me.

Project: pm-ws/
- Python 3.10+ (run with `python3`), `requests`, `websocket-client`,
  `pytest`. No SDK needed.
- markets.py
  - GAMMA https://gamma-api.polymarket.com, CLOB https://clob.polymarket.com.
  - resolve(slugs): GET {GAMMA}/markets?slug=<slug> for each slug. The
    response is a LIST. `outcomes` and `clobTokenIds` are JSON-ENCODED
    STRINGS: json.loads them. Return {token_id: {"slug", "outcome",
    "question"}}. Skip (with a printed reason) markets that are closed or
    have enableOrderBook false. Outcomes are not always Yes/No (sports use
    team names): label by the outcome string.
  - top_markets(n): GET {GAMMA}/markets?closed=false&order=volume24hr
    &ascending=false&limit=3n, same mapping. Skip acceptingOrders false.
    Do NOT filter with end_date_min: for sports, endDate is the KICKOFF
    time, so it drops every game in progress, which are the busiest books
    (on Oct 25 at 2 pm ET it hid 5 of the top 11, incl. #1 Browns-Titans,
    $3.9M 24h volume). Instead skip (unless --include-decided) markets
    whose Gamma bestBid or bestAsk is missing, <= 0.01 or >= 0.99: they
    are effectively decided, one side of the book is empty and nothing
    moves (the #2 market that day was a finished esports match at
    0.999 / 0.001). Keep the first n markets that pass.
  - rest_book(token_id): GET {CLOB}/book?token_id=<id>. Bids come sorted
    ASCENDING and asks DESCENDING, so never take element [0]: best bid =
    max price, best ask = min price.
  - Back off and retry on HTTP 429; timeouts on every request.
- book.py
  - LocalBook(asset_id): bids and asks as dicts keyed by the price STRING
    normalised with decimal.Decimal (so "0.5" and "0.50" are one level),
    size as Decimal. ready=False until the first snapshot.
  - apply_snapshot(msg): a `book` event REPLACES both sides (it's sent on
    subscribe and again after trades). Store hash and timestamp; also
    store the message timestamp on every applied price_change.
  - apply_change(ch): a `price_change` entry {asset_id, price, size, side,
    hash, best_bid, best_ask}. side BUY -> bids, SELL -> asks. `size` is the
    NEW AGGREGATE size at that price, not a delta: set it; "0" removes
    the level. Ignore changes for a book that isn't ready (count them).
  - best_bid() = max bid price, best_ask() = min ask price, spread, mid
    (None when a side is empty).
  - Server sentinels: when a side is EMPTY the server sends best_bid "0"
    and best_ask "1" (not empty strings). Treat "0"/"1" there as None, or
    every one-sided book (common near resolution) fails every check.
  - Server best-price check, done per BATCH, not per message: one match
    (a taker sweeping several levels) arrives as several price_change
    messages with the SAME timestamp, and every entry already carries the
    post-match best_bid/best_ask while the level removals trickle in.
    Keep, per asset, the timestamp and server best_bid/best_ask of its
    last applied entry. Settle (compare local best with that server
    best) when a price_change for that asset arrives with a DIFFERENT
    timestamp (settle before applying it), when the stream has been idle
    for 1 s, and at exit. A `book` snapshot discards the pending batch.
    Count checks and mismatches. If the local book is locked or crossed
    at settle time, skip and count crossed_skipped. 3 mismatched batches
    in a row on one asset -> mark it not ready and ask the stream to
    resync (reconnect, which brings a fresh snapshot).
- stream.py
  - URL wss://ws-subscriptions-clob.polymarket.com/ws/market (public, no
    auth). After connecting send {"assets_ids": [...], "type": "market"}
    (add "custom_feature_enabled": true with --custom to also get
    best_bid_ask, new_market and market_resolved). One connection for all
    assets. new_market and market_resolved are sent for ALL markets, not
    just yours (about 90 a minute on Oct 25): count them, log them, never
    print them per event.
  - Heartbeat: send the TEXT frame "PING" right after subscribing and then
    every 10 seconds; the server answers the text "PONG". Never json.loads
    "PONG"; any other non-JSON text: log and ignore.
  - A frame can be ONE JSON object or a JSON LIST of events (the initial
    book dump arrives as a list). Handle both. Dispatch on event_type:
    book, price_change, last_trade_price, tick_size_change, best_bid_ask,
    new_market, market_resolved; count unknown types, never crash.
  - Watchdog: if nothing at all (not even PONG) arrives for --stale-after
    seconds (default 30), treat the socket as dead.
  - Reconnect on any close, error, dead socket or resync request: CLEAR
    every book (state from the old socket is invalid), back off 1, 2, 4,
    ... max 30 s with jitter, reset the backoff once a message arrives,
    resubscribe and wait for new snapshots. Count reconnects.
  - --drop-after S: close the socket once, S seconds in, to prove the
    reconnect path works on the live server.
  - `connect` is injectable so tests use a fake socket. Ctrl-C exits
    cleanly with the stats line.
- pmws.py (argparse):
  - stream (--slug SLUG ... | --top N [--include-decided]) [--duration S] [--print-every 15]
    [--custom] [--log events.jsonl] [--drop-after S] [--audit-every S]
    [--stale-after 30]. Every --print-every seconds print one line per
    outcome: slug, outcome, best bid, best ask, spread, levels per side,
    last trade, seconds since last update. Print each last_trade_price as
    "TRADE <slug> [<outcome>] <side> <size> @ <price>" (side is the
    taker's side). --audit-every: fetch rest_book for each ready asset and
    compare best bid/ask with the local book. The REST book has a
    `timestamp` (ms, same clock as the socket events), and LocalBook keeps
    the timestamp of the last event it applied. Never sleep inside the
    audit: the receive loop must keep running, or the local book freezes
    while REST moves on (a blocking 2 s re-check made busy in-play books
    fail 13 of 90 audits on Oct 25). On a difference where the REST
    timestamp is NEWER than the local one, schedule a re-check 2 s later
    from the main loop, up to 2 times. Report "audit OK" on a match,
    "audit MOVED" if REST was still ahead after the re-checks (the book
    moves faster than one REST call; not an error), and "audit DIFF"
    with both values and timestamps only when the local book had already
    seen everything REST had (REST timestamp <= local) and still
    disagrees. At exit print stats:
    frames, events by type, pongs, reconnects, server best-price checks
    and mismatches, crossed_skipped, audits OK/MOVED/DIFF, changes dropped
    before a snapshot. Print the key counters even when they are 0.
  - book (--slug SLUG | --token ID): one REST book with best bid/ask.
  - Banner: "Public Polymarket market data, read-only. Not advice."
- tests/conftest.py inserting the project root into sys.path.
- tests/ (pytest, no network): Gamma JSON-string decoding; REST best =
  max bid / min ask on ascending/descending arrays; top_markets skips
  decided markets (bestAsk 0.001, missing bestBid); snapshot replaces;
  aggregate size set (not added) and "0" removes; "0.5" vs "0.50" one
  level; changes before a snapshot are dropped; server best-price check
  counts a mismatch and 3 mismatched batches in a row trigger resync;
  server "0"/"1" on an empty side matches a local empty side; a 3-level
  sweep sent as 3 messages with one timestamp is ONE check with 0
  mismatches; a new timestamp settles the previous batch; list and
  object frames; a locked book at settle time is skipped, not counted;
  top_markets sends no end_date_min and skips acceptingOrders false;
  "PONG" and junk text don't crash; first PING sent right after
  subscribe and then on the interval (fake clock); reconnect clears
  books and resubscribes; an audit where REST is newer schedules a
  re-check without sleeping, and REST <= local with different prices is
  a DIFF; watchdog fires on silence; and a grep test that the
  APP modules (not tests/) contain none of "private_key", "eth_account",
  "ws/user", "post_order", "create_order", "/order", "api_secret".
- .gitignore (.venv/, __pycache__/, *.jsonl), README with the commands.

Show me the file plan first. After writing, tell me exactly what to run.
Enter fullscreen mode Exit fullscreen mode

Then:

python3 -m venv .venv && source .venv/bin/activate
pip install requests websocket-client pytest
python3 -m pytest -q
python3 pmws.py book --slug <market-slug>
python3 pmws.py stream --top 5 --duration 150 --print-every 30 --audit-every 40 --drop-after 60 --custom --log events.jsonl
Enter fullscreen mode Exit fullscreen mode

In the final stats line, pongs should equal pings (or trail by one), reconnects should be 1, and server_mismatches and audit_diff should be 0. No account or key is needed.

Already built an earlier version?

Paste this into the same project:

Update my pm-ws streamer:
1. Server sentinels: when a side is empty the server sends best_bid "0"
   and best_ask "1". Treat those as None before comparing, so a
   one-sided book doesn't fail every check.
2. Check per batch, not per message. Per asset keep the timestamp and
   server best_bid/best_ask of the last applied price_change entry.
   Compare (settle) when a price_change for that asset arrives with a
   different timestamp (before applying it), after 1 s of silence, and
   at exit. A `book` snapshot discards the pending batch. 3 mismatched
   batches in a row -> resync. Keep the crossed_skipped rule.
3. LocalBook: store the message timestamp on every applied price_change.
4. Audit without sleeping. Compare REST /book best bid/ask with local.
   If they differ and the REST `timestamp` is newer than the local one,
   schedule a re-check 2 s later from the main loop (up to 2). Report
   "audit OK", "audit MOVED" (REST stayed ahead) or "audit DIFF" (only
   when REST timestamp <= local and prices still differ). Stats: audits
   OK/MOVED/DIFF.
5. top_markets: drop end_date_min (sports endDate is kickoff, so it
   hides games in progress). Fetch limit=3n, skip acceptingOrders false
   and, unless --include-decided, markets whose Gamma bestBid or bestAsk
   is missing, <= 0.01 or >= 0.99. Keep the first n that pass.
6. new_market and market_resolved arrive for ALL markets: count and log
   them, don't print them.
Add tests for each, then run pytest and one 150 s stream.
Enter fullscreen mode Exit fullscreen mode

What this is and isn't

The streamer only reads. It never connects to the authenticated user channel, holds no key, and a test fails if any app module mentions private_key, eth_account, ws/user, post_order, create_order, /order or api_secret. Its output is a top-of-book table and a list of trades. It doesn't rank, score or suggest anything, and nothing here is financial advice. If you want to try a strategy on top of the live books, feed them into a paper broker first.

The full recipe, with step-by-step setup, the FAQ and links to every Polymarket doc we checked, is at botcookery.com/recipes/polymarket-websocket-starter. For REST books, fees, L1/L2 auth and a paper broker on the same CLOB, see the Polymarket CLOB API starter.

Top comments (0)