<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[BotCookery]]></title><description><![CDATA[BotCookery]]></description><link>https://botcookery.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 30 Oct 2026 15:13:03 GMT</lastBuildDate><atom:link href="https://botcookery.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Hyperliquid Funding Rates in Python: 4 Lessons From a Live Read-Only Monitor]]></title><description><![CDATA[Originally published on BotCookery, a free site of build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every output…]]></description><link>https://botcookery.hashnode.dev/hyperliquid-funding-rates-python-lessons-live-monitor</link><guid isPermaLink="true">https://botcookery.hashnode.dev/hyperliquid-funding-rates-python-lessons-live-monitor</guid><category><![CDATA[Python]]></category><category><![CDATA[api]]></category><category><![CDATA[Blockchain]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[BotCookery]]></dc:creator><pubDate>Fri, 30 Oct 2026 14:18:42 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on <a href="https://botcookery.com/recipes/hyperliquid-funding-rate-monitor/">BotCookery</a>, a free site of build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every output below is from real runs against Hyperliquid's public API, on Oct 25, 2026 (the rebuild) and Oct 29, 2026 (a re-run of the rebuilt code). The monitor holds no key and can't place an order, and nothing here is financial advice.</em></p>
<p>If you sort every Hyperliquid perp by funding rate, the top of the list is almost never a market you've heard of. On the morning we tested our funding-rate monitor, the loudest number on the exchange was POLYX at about -1,652% APR. A few days later, POLYX had $113k of 24-hour volume. BTC had $2.3B.</p>
<p>That's the first of four lessons from rebuilding a small read-only Python monitor from its own AI prompt and pointing it at live data. None of them showed up in unit tests. All of them showed up within the first twenty minutes of real output.</p>
<h2>What the monitor is</h2>
<p>The monitor is a prompt, not a repo. You paste it into an AI coding tool in an empty folder and it builds a Python CLI with three commands:</p>
<ul>
<li><code>snapshot</code>: current and predicted funding for every perp, annualized and labelled, next to Binance's and Bybit's predicted rates.</li>
<li><code>history</code>: hourly funding prints for one coin.</li>
<li><code>watch</code>: a loop that alerts to the console or a webhook when a rate crosses a threshold you set.</li>
</ul>
<p>It only calls the public <code>POST /info</code> endpoint. No key, no wallet, no <code>/exchange</code>. On Oct 25 we pasted the published prompt into a clean folder, let it build, and ran every command on live data, including a watch loop with the webhook pointed at a local receiver. The API facts in the prompt held up. The program built from it didn't, in four ways.</p>
<h2>Hyperliquid funding, the short version</h2>
<p>Everything goes to <code>POST https://api.hyperliquid.xyz/info</code> (testnet: <code>api.hyperliquid-testnet.xyz</code>):</p>
<ul>
<li><code>{"type":"metaAndAssetCtxs"}</code> returns <code>[meta, assetCtxs]</code>. Zip <code>meta.universe[i]</code> with <code>assetCtxs[i]</code> and read <code>funding</code>, the current <strong>hourly</strong> rate.</li>
<li><code>{"type":"predictedFundings"}</code> returns Hyperliquid's predicted rate next to Binance's and Bybit's, each with its own <code>fundingIntervalHours</code>.</li>
<li><code>{"type":"fundingHistory","coin":"ETH","startTime":...}</code> returns hourly prints, at most 500 rows per call.</li>
</ul>
<p>Hyperliquid pays funding every hour, at one-eighth of an 8-hour formula rate. A quiet market sits at 0.00125% per hour, which is 11.0% a year if you multiply by 8,760. That annual figure is a comparison convention, not a forecast.</p>
<h2>Lesson 1: a funding rate without volume is noise</h2>
<p>The first build sorted perps by absolute APR and alerted on anything over the threshold. First loop of its watch at 45%, Oct 25, 09:30 UTC (trimmed):</p>
<pre><code class="language-text">POLYX: funding -0.18856%/h, APR -1651.79% (simple), predicted APR -1653.79%, shorts pay longs; threshold 45%. Informational only.
SOPH: funding -0.02811%/h, APR -246.24% (simple), predicted APR -248.55%, shorts pay longs; threshold 45%. Informational only.
ALT: funding -0.02078%/h, APR -182.04% (simple), predicted APR -181.72%, shorts pay longs; threshold 45%. Informational only.
...
[09:30:08 UTC] loop 1: 10 new alerts
</code></pre>
<p>Thin perps drift further from the oracle price, so their premium, and their funding, swings harder. When we re-ran the rebuilt code on Oct 29, POLYX had $113k of 24-hour volume and $99k of open interest. On that run, 111 of the 179 live perps had under $1M of 24-hour volume. A list sorted by rate alone is mostly a list of those.</p>
<p>The fix was to never show a rate without its context. Every row and every alert now carries 24-hour volume and open interest in dollars. The snapshot hides perps under $1M of volume by default and says how many it hid. Oct 29 re-run:</p>
<pre><code class="language-text">MAINNET funding at 2026-10-29 13:54 UTC. APR = hourly x24x365, simple, not a forecast.
coin      id     hourly       APR      pred          Binance            Bybit  24h vol       OI
BLUR      62  -0.12221%  -1070.6%  -1070.0%         -1375.2%         -1431.9%    $4.2M    $1.0M  over
PURR     152  +0.02696%   +236.2%   +238.4%                -                -    $3.0M   $13.8M  over
NIL      186  -0.00543%    -47.6%    -47.3%           -66.0%           +11.0%    $1.3M    $2.7M
HYPE     159  +0.00437%    +38.3%    +38.6%                -                -    $1.1B    $2.1B
...
BTC        0  +0.00125%    +11.0%    +11.0%           +11.0%            +5.8%    $2.3B    $3.5B
ETH        1  +0.00125%    +11.0%    +11.0%           +10.4%            +7.2%  $985.2M    $2.6B
111 perps under $1.0M 24h volume hidden (--min-volume 0 shows all)
</code></pre>
<p>(The real output prints an em dash where a venue doesn't list the coin; it's shown as <code>-</code> here.)</p>
<p>A volume filter isn't a verdict. BLUR cleared the $1M bar and still showed about -1,071% APR, with Binance and Bybit predicting even more negative rates. The filter only stops the very thinnest books from taking over the list. <code>--min-volume 0</code> shows everything, and that's where GMT, POLYX and GAS sat, between -65% and -75% APR on under $120k of volume each.</p>
<h2>Lesson 2: retry more than 429</h2>
<p>During the first watch, <code>/info</code> returned 502s and then 15-second read timeouts:</p>
<pre><code class="language-text">[09:32:42 UTC] loop 6: 0 new alerts
error: 502 Server Error: Bad Gateway for url: https://api.hyperliquid.xyz/info
error: 502 Server Error: Bad Gateway for url: https://api.hyperliquid.xyz/info
error: HTTPSConnectionPool(host='api.hyperliquid.xyz', port=443): Read timed out. (read timeout=15)
error: HTTPSConnectionPool(host='api.hyperliquid.xyz', port=443): Read timed out. (read timeout=15)
[09:35:45 UTC] loop 11: 0 new alerts
</code></pre>
<p>The first build's <code>post_info</code> retried only on HTTP 429. It happened to wrap the loop in <code>try/except</code>, so it limped on, but four loops in a row were lost, and the prompt never asked for either behaviour. The next AI build could just as easily have crashed on the first 502. A prompt that doesn't spell out failure handling gets whatever the model felt like writing that day.</p>
<p>The prompt now says it outright: retry 429, any 5xx, timeouts and connection errors with backoff (1, 2, 4 seconds, at most 3 retries), then raise one error type. <code>watch</code> catches it, prints <code>[HH:MM:SS UTC] loop N: API error (&lt;reason&gt;), retrying next loop</code>, and carries on. Each part has a test.</p>
<p>A related fix: the first build's <code>print</code> calls didn't flush. When stdout is a file instead of a terminal, Python block-buffers it, so <code>nohup python3 monitor.py watch &gt; funding.log &amp;</code> can sit there looking empty while alerts are firing. Every print in the rebuilt version uses <code>flush=True</code>.</p>
<h2>Lesson 3: funding moves inside the hour, so alerts need a re-arm band</h2>
<p>Hyperliquid's <code>funding</code> field doesn't change once an hour. It updates continuously. WLD was at -50.1% APR at 09:30 UTC and -54.2% at 09:44. An alert that re-arms the moment a coin dips under the line will fire again and again for a coin hovering near it.</p>
<p>The rebuilt alerter fires once when a coin crosses, stays quiet while it stays over, and re-arms only after the absolute APR drops below 90% of the threshold. Rebuilt version, 20 one-minute loops at 50%, webhook pointed at a local receiver, Oct 25:</p>
<pre><code class="language-text">watching mainnet | apr &gt;= 50% either sign | 24h vol &gt;= $1.0M | every 60s. Informational only.
WLD: funding -0.00618%/h = -54.2% APR (simple), predicted -54.3%; shorts pay longs; 24h vol $38.5M, OI $66.2M; threshold 50%. Informational only.
ANIME: funding -0.04260%/h = -373.1% APR (simple), predicted -373.0%; shorts pay longs; 24h vol $2.7M, OI $585.5k; threshold 50%. Informational only.
ZORA: funding -0.00936%/h = -82.0% APR (simple), predicted -82.5%; shorts pay longs; 24h vol $1.8M, OI $763.5k; threshold 50%. Informational only.
XMR: funding +0.00749%/h = +65.6% APR (simple), predicted +65.7%; longs pay shorts; 24h vol $37.2M, OI $101.2M; threshold 50%. Informational only.
[09:44:23 UTC] loop 1: 4 perps over, 4 new alerts
[09:45:23 UTC] loop 2: 4 perps over, 0 new alerts
...
[09:59:36 UTC] loop 16: 4 perps over, 0 new alerts
[10:00:37 UTC] loop 17: 2 perps over, 0 new alerts
...
stopped after 20 loops: 4 alerts, 0 errors
</code></pre>
<p>Four alerts, each delivered once to the receiver, no repeats. Loop 17 is the interesting line: the funding hour turned over at 10:00 UTC and half the list dropped back under the line. A rate that looks extreme at :50 can be gone at :05. That's why each alert reports a level, says which side pays, and ends with "Informational only". It doesn't suggest anything.</p>
<p>The Oct 29 re-run behaved the same way: BLUR and PURR alerted once on loop 1, loops 2 and 3 said <code>2 perps over, 0 new alerts</code>, and the run ended with <code>2 alerts, 0 errors</code>.</p>
<h2>Lesson 4: an unknown coin is HTTP 500, not an empty list</h2>
<p>The original prompt said an unknown coin would return an empty list. It doesn't. Ask for lowercase <code>eth</code> and you get this (re-checked Oct 29):</p>
<pre><code class="language-text">$ curl -s -X POST https://api.hyperliquid.xyz/info -H 'content-type: application/json' \
    -d '{"type":"fundingHistory","coin":"eth","startTime":1761700000000}' -w ' HTTP %{http_code}\n'
null HTTP 500
</code></pre>
<p>Coin names are case-sensitive, and some are mixed case (<code>kPEPE</code>, <code>kBONK</code>), so uppercasing everything doesn't work either. The rebuilt <code>history</code> command looks the name up in <code>{"type":"meta"}</code> case-insensitively and sends the exact spelling. <code>history eth</code> works, and <code>history nope</code> prints <code>unknown coin nope on mainnet</code> without calling <code>fundingHistory</code> at all.</p>
<p>History is where the long view lives. 600 hours of ETH is more than one 500-row page, so this also exercises pagination (Oct 29):</p>
<pre><code class="language-text">600 hours: mean +0.00080%/h (+7.0% APR simple), min -0.00248%, max +0.00125%, 301 hours at the 0.00125% baseline
</code></pre>
<p>Roughly half of those 600 hours sat exactly at the quiet baseline. Spikes are the exception, which is one more reason a single snapshot can mislead.</p>
<h2>A note on testnet</h2>
<p>Testnet books are thin, so Hyperliquid's own testnet funding isn't realistic. On Oct 29, testnet SOL showed +4,087% APR and BTC -603%. But the Binance and Bybit columns in testnet's <code>predictedFundings</code> are the real exchanges' rates, identical to mainnet's (BTC: +11.0% and +5.8% on both). Asset IDs differ too: SOL was id 0 on testnet and 5 on mainnet. Join by coin name, never by index. The snapshot footer now says all of this.</p>
<h2>Already built it? Paste this follow-up prompt</h2>
<p>If you built the monitor from an earlier version of the recipe, paste this into the same project:</p>
<pre><code class="language-text">Update my Hyperliquid funding monitor:
1. post_info: 15 s timeout; retry with backoff (1, 2, 4 s, at most 3
   retries) on HTTP 429, any 5xx, timeouts and connection errors, then
   raise HLError. watch must catch HLError, print
   "[HH:MM:SS UTC] loop N: API error (&lt;reason&gt;), retrying next loop" and
   carry on. Every print uses flush=True so a redirected log updates live.
2. Add day_volume_usd (dayNtlVlm) and oi_usd (openInterest * oraclePx) to
   each row. snapshot shows both, takes --min-volume (default 1000000;
   ignored with --coins) and prints "N perps under $X 24h volume hidden".
   watch applies the same filter.
3. Alerts re-arm only after |APR| drops below 0.9 x threshold, and the
   message includes 24h volume and OI.
4. fundingHistory returns HTTP 500 with body null for an unknown or
   wrong-case coin. Add resolve_coin(name, testnet) that matches the name
   case-insensitively against {"type":"meta"} universe names (kPEPE is
   mixed case) and use it in history; unknown names print
   "unknown coin &lt;name&gt; on &lt;net&gt;" and exit 1.
Add tests for the retry, the volume filter, the re-arm band and
resolve_coin. Run them and show me the diff. Still read-only: no keys,
no /exchange, no trade suggestions.
</code></pre>
<p>Starting fresh? The full prompt is on the recipe page. After it builds:</p>
<pre><code class="language-bash">python3 -m venv .venv &amp;&amp; source .venv/bin/activate
pip install requests pytest
python3 -m pytest -q
python3 monitor.py snapshot --top 15 --threshold 50
python3 monitor.py snapshot --top 15 --min-volume 0
python3 monitor.py history ETH --hours 600 --last 12
python3 monitor.py watch --threshold 50 --interval 60 --max-loops 3
</code></pre>
<p>The rebuilt version has 14 tests, all offline with a fake HTTP session. They passed again on the Oct 29 re-run.</p>
<h2>Scope and limits</h2>
<p>The page's verified line, word for word: <em>Verified working on live data in read-only mode, Oct 25, 2026.</em> Its stated limit: <em>Public mainnet and testnet data, including a 20-minute watch across a funding hour. Webhook alerts went to a local receiver, not a real Slack or Discord channel.</em></p>
<p>The monitor has no key, no wallet and no signing library, and a test fails if any app module mentions <code>/exchange</code>, <code>private_key</code>, <code>eth_account</code> or <code>Exchange(</code>. It reports who pays whom and how much, annualized by convention. It doesn't rank opportunities or tell you to do anything, and a high funding rate on its own isn't a trading signal.</p>
<p>The full recipe, with the complete prompt, step-by-step setup, the FAQ and links to every Hyperliquid doc we checked, is at <strong><a href="https://botcookery.com/recipes/hyperliquid-funding-rate-monitor/">botcookery.com/recipes/hyperliquid-funding-rate-monitor</a></strong>. For the API basics (endpoints, asset IDs, rate limits) see our <a href="https://botcookery.com/recipes/hyperliquid-api-starter-testnet/">Hyperliquid API starter</a>, and for large trades and public positions, the read-only <a href="https://botcookery.com/recipes/hyperliquid-whale-tracker/">whale tracker</a>.</p>
]]></content:encoded></item><item><title><![CDATA[The Busiest Polymarket Markets Crashed Our Order Book Code. They Had Just Finished.]]></title><description><![CDATA[A read-only + paper Python starter for the international Polymarket CLOB, rebuilt from its AI prompt and run on live data: one-sided books on resolving…]]></description><link>https://botcookery.hashnode.dev/polymarket-clob-busiest-markets-crashed-order-book-code</link><guid isPermaLink="true">https://botcookery.hashnode.dev/polymarket-clob-busiest-markets-crashed-order-book-code</guid><category><![CDATA[Python]]></category><category><![CDATA[api]]></category><category><![CDATA[Web3]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[BotCookery]]></dc:creator><pubDate>Thu, 29 Oct 2026 14:02:33 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on <a href="https://botcookery.com/recipes/polymarket-clob-api-starter/">BotCookery</a>, a free site of build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every log line 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.</em></p>
<p>Here's a bug you won't find in a unit test. On a Sunday morning, three of the 20 busiest international Polymarket markets crashed our order-book code on every single read. Not thin, forgotten markets: a Dota 2 game, a CS2 match and a Russia-Ukraine diplomacy market, all in the top 20 by 24-hour volume and all still accepting orders. They were busy because they had just finished.</p>
<p>The code came from our Polymarket CLOB API starter, which is a prompt rather than a repo. You paste it into an AI coding tool in an empty folder and get a small Python toolkit for the international Polymarket order book: Gamma market lookups, the CLOB <code>/book</code> 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.</p>
<p>On Oct 25 we put that prompt to the test. We pasted the published version into a clean folder, let it build, and ran <code>book</code> on each of the top 20 markets by 24-hour volume about every 50 seconds for roughly 6 minutes, plus a paper round trip and the auth demo.</p>
<p>Plenty held up. The sorted book's mid matched both the SDK's <code>PublicClient().get_midpoint()</code> and raw <code>/midpoint</code> 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. Below is what didn't, and what we changed in the prompt.</p>
<h2>The three hosts, in one paragraph</h2>
<p>International Polymarket splits its public API across three hosts. <strong>Gamma</strong> (<code>https://gamma-api.polymarket.com</code>) has markets and events: questions, slugs, and <code>clobTokenIds</code>, which are JSON-encoded strings you have to <code>json.loads</code>. The <strong>CLOB</strong> (<code>https://clob.polymarket.com</code>) has order books, midpoints and, for authenticated users, orders. The <strong>Data API</strong> (<code>https://data-api.polymarket.com</code>) has trades, positions and price history. Reads need no key.</p>
<p>The classic gotcha is still there: raw <code>/book</code> lists bids ascending and asks descending, so the best price on each side is the <strong>last</strong> element. Our original prompt already said to sort. That part was fine.</p>
<h2>1. Finished markets have one-sided books, and max() hates that</h2>
<p>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:</p>
<pre><code>[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
</code></pre>
<p>Same three markets, every time:</p>
<pre><code>== dota2-liquid-og-2026-10-25-game1 [0] rc=1
  File ".../polymarket-clob-starter/cli.py", line 41, in &lt;module&gt;
ValueError: max() iterable argument is empty
== dota2-liquid-og-2026-10-25-game1 [1] rc=1
ValueError: min() iterable argument is empty
</code></pre>
<p>These weren't dead markets. A Dota 2 game and a CS2 match had a result <strong>proposed</strong> through UMA, and a Russia-Ukraine diplomacy market had a result <strong>disputed</strong>. All three were still active and accepting orders, and all three sat in the top 20 by 24h volume. Their books were one-sided: <code>0.999</code> bids with nothing on the ask, or <code>0.001</code> asks with nothing on the bid, for hundreds of thousands of shares.</p>
<p>Anyone writing a scanner, dashboard or bot loop should plan for this case first. The markets everyone is trading right after a match are the ones whose result has just been proposed or disputed, and that's when one side of the book empties out.</p>
<p>The fix in the prompt: either side can be empty, so <code>best_bid</code>/<code>best_ask</code> become <code>None</code>, and so do <code>mid</code> and <code>spread</code>. Never call <code>max()</code>/<code>min()</code> on an empty list. <code>book</code> prints <code>no bids</code> / <code>no asks</code>. <code>markets</code> now reads <code>uma_resolution_status</code> and <code>accepting_orders</code> and labels each market, and the paper broker refuses anything that isn't open.</p>
<h2>2. Watching a market resolve in real time</h2>
<p>The rebuilt version has a <code>scan</code> 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:</p>
<pre><code>  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
</code></pre>
<p>After four loops, the CS2 market finished resolving (it closed at 10:18:20 UTC), and its book disappeared:</p>
<pre><code>  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
</code></pre>
<p>Two things happen at resolution, and both look like bugs in your code if you don't know about them:</p>
<ul>
<li>The CLOB deletes the book. <code>/book</code> returns <strong>HTTP 404 "No orderbook exists for the requested token id"</strong>.</li>
<li>Gamma's <code>/markets?slug=</code> returns an <strong>empty list</strong>, because closed markets are hidden unless you pass <code>closed=true</code>.</li>
</ul>
<p>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 <code>closed=true</code>, and reports the close time, <code>umaResolutionStatus</code> and final prices instead of a traceback. The 8-loop scan logged the 404 and kept going: 0 crashes.</p>
<h2>3. Don't read live prices off Gamma</h2>
<p>It's tempting to read <code>bestBid</code>, <code>bestAsk</code> or <code>outcomePrices</code> straight off Gamma, since it's already in the market object. During a live CS2 match, Gamma said <strong>0.79/0.80</strong> while the book and <code>/midpoint</code> said <strong>0.85/0.86</strong>. Gamma's <code>orderPriceMinTickSize</code> also said <code>0.01</code> on a market whose book had already moved to a <code>0.001</code> tick (tick size tightens as prices approach 0 or 1).</p>
<p>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.</p>
<p>One more API quirk we checked again: <code>GET /price?side=BUY</code> returned the <strong>best bid</strong> on Oct 22 and again on Oct 25, while the side naming in the docs reads the other way. We don't use <code>/price</code> for the top of book at all. Derive bid and ask from the sorted book.</p>
<h2>4. That slug you copied is probably an event</h2>
<p>Copy a slug out of a <code>polymarket.com/event/...</code> URL and you often have an <strong>event</strong> slug, not a market slug. <code>book brazil-presidential-election</code> failed with "no market with slug", even though that event has 32 markets.</p>
<p>The rebuilt <code>gamma_market()</code> checks closed markets first (many single-match events share their slug with the main market), then tries <code>/events?slug=</code>, and if it finds an event, lists its first 10 market slugs so you can pick one.</p>
<h2>5. Read the fee rate from each market, not the docs table</h2>
<p>International Polymarket's taker fee is <code>shares x rate x p x (1 - p)</code>, rounded to 5 decimals in USDC, with the rate in each market's own <code>feeSchedule</code>. The docs have a category table, so it's tempting to hardcode it. Live rates that morning: <strong>0</strong> on the Brazil election and Fed-rate markets, <strong>0.05</strong> on tennis and esports, and <strong>0.03</strong> on the Ballon d'Or and an NFL total. The docs' table has no 0.03 category.</p>
<p>Our prompt already said to read <code>feeSchedule</code> per market. The run is the reason it now says, in capitals, never to hardcode a category rate.</p>
<h2>6. "No liquidity" was the wrong error</h2>
<p>The paper broker only simulates <strong>taker</strong> 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.</p>
<p>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:</p>
<pre><code>{"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"}
</code></pre>
<p>The fee checks out: 5 x 0.05 x 0.94 x 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.</p>
<p>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 <code>KILL</code> file existed.</p>
<h2>The L1 / L2 auth part, briefly</h2>
<p>This is the bit people search for ("how do I get a Polymarket API key"), and the starter only explains it, offline:</p>
<ul>
<li><strong>L1</strong> is an EIP-712 signature (domain <code>ClobAuthDomain</code>, version <code>1</code>, chain 137) proving you control a wallet. It's used once, to create or derive API credentials: key, secret, passphrase.</li>
<li><strong>L2</strong> is an HMAC-SHA256 over <code>timestamp + METHOD + path + body</code>, keyed with the urlsafe-base64-<strong>decoded</strong> secret, sent as <code>POLY_ADDRESS</code>, <code>POLY_SIGNATURE</code>, <code>POLY_TIMESTAMP</code>, <code>POLY_API_KEY</code> and <code>POLY_PASSPHRASE</code> headers on every trading call.</li>
</ul>
<p><code>auth_explain.py</code> builds both with a key made in memory by <code>eth_account.Account.create()</code> 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.</p>
<h2>If you built it before Oct 25</h2>
<p>Paste this follow-up prompt into the same project:</p>
<pre><code class="language-text">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 &lt;closedTime&gt; (&lt;umaResolutionStatus&gt;), 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.
</code></pre>
<p>Starting fresh? The full prompt is on the recipe page. After it builds:</p>
<pre><code class="language-bash">python3 -m venv .venv &amp;&amp; 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 &lt;slug-from-the-list&gt; 0
python3 cli.py scan --n 20 --loops 5 --interval 60 | tee scan.log
python3 cli.py paper &lt;slug&gt; 0 &lt;best-ask&gt; 5
python3 auth_explain.py
</code></pre>
<h2>What this is and isn't</h2>
<p>The page's verified line, word for word: <em>Verified working on live data in read-only and paper mode, Oct 25, 2026.</em> 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.</p>
<p>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.</p>
<p>The full recipe, with the complete prompt, step-by-step setup, the FAQ and links to every Polymarket doc we checked, is at <strong><a href="https://botcookery.com/recipes/polymarket-clob-api-starter/">botcookery.com/recipes/polymarket-clob-api-starter</a></strong>. If you'd rather stream the same books than poll them, see our <a href="https://botcookery.com/recipes/polymarket-websocket-starter/">Polymarket WebSocket starter</a>, which ran into the same one-sided books from the other direction.</p>
]]></content:encoded></item><item><title><![CDATA[Our Hyperliquid Bot Refused Every $25 Order. The $25 Cap Was the Bug.]]></title><description><![CDATA[A testnet-only Python starter for the Hyperliquid API, the four problems a live rebuild found (a cap that rounding always broke, tradable-looking delisted…]]></description><link>https://botcookery.hashnode.dev/hyperliquid-api-starter-refused-every-25-dollar-order</link><guid isPermaLink="true">https://botcookery.hashnode.dev/hyperliquid-api-starter-refused-every-25-dollar-order</guid><category><![CDATA[Python]]></category><category><![CDATA[api]]></category><category><![CDATA[Blockchain]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[BotCookery]]></dc:creator><pubDate>Wed, 28 Oct 2026 14:06:46 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on <a href="https://botcookery.com/recipes/hyperliquid-api-starter-testnet/">BotCookery</a>, a free site of build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every number below comes from real runs against Hyperliquid's public API on Oct 25, 2026. Signed calls are testnet only, and nothing here is financial advice or a trading signal.</em></p>
<p>We publish a copy-paste prompt that has an AI coding tool build a small Python starter for the Hyperliquid API: reads on mainnet or testnet, and signed orders on testnet only, capped at $25. On Oct 25 we checked whether that prompt actually holds up. We pasted the Oct 22 version into a clean folder, let it build, and ran it live: reads on both networks, a dry-run order for every listed perp, and signed testnet calls with a throwaway key that held no funds.</p>
<p>Most of it worked. Reads, asset lookups, price rounding, the <code>--mainnet</code> flag and the testnet-only guard all did what they should. Every one of the 158 live testnet perps and 178 live mainnet perps got a valid price at $10.</p>
<p>Then we asked for $25, and the bot refused. Every time. Here are the four things that were wrong, and how the prompt changed.</p>
<h2>Hyperliquid in one paragraph</h2>
<p>Everything is a JSON POST. Reads go to <code>/info</code> with a <code>type</code> (<code>meta</code>, <code>allMids</code>, <code>l2Book</code>, <code>candleSnapshot</code>, <code>clearinghouseState</code>, <code>openOrders</code>) and need no signature. Orders go to <code>/exchange</code> and are signed, which is where the official <code>hyperliquid-python-sdk</code> earns its keep. Testnet lives at <code>api.hyperliquid-testnet.xyz</code>, mainnet at <code>api.hyperliquid.xyz</code>, and they are different markets with different prices. The first lines of each of our cycles were testnet mids, then mainnet mids:</p>
<pre><code>BTC        80973.5 ETH        2352.75 SOL        101.445
BTC        80646.5 ETH        2354.95 SOL        101.925
</code></pre>
<p>A few rules the prompt spells out, because an AI tool will otherwise guess:</p>
<ul>
<li><strong>Asset IDs differ between networks.</strong> Look up the index and <code>szDecimals</code> from <code>meta()</code> on the same network every time. Never hardcode them.</li>
<li><strong>Prices:</strong> at most 5 significant figures and at most <code>6 - szDecimals</code> decimals for perps. Integer prices are always valid.</li>
<li><strong>$10 minimum</strong> notional per order.</li>
<li><strong>Agent wallets have no balance.</strong> You approve an agent key to sign for your main account, but balance and open-order lookups must use the main address.</li>
</ul>
<h2>1. A $25 order could never pass</h2>
<p>The starter places a post-only (<code>Alo</code>) limit 10% away from the mid, so the order rests instead of filling. Size is <code>usd / limit</code>, rounded <em>up</em> to <code>szDecimals</code>. Rounding up is deliberate: rounding down, or sizing on the mid, leaves a $10 buy under the $10 exchange minimum.</p>
<p>But rounding up always lands the notional a little over what you asked for. And the cap compared that notional to $25. So in the original build, <code>plan BTC buy 25</code> printed:</p>
<pre><code>refusing: notional 25.51 &gt; $25.0
</code></pre>
<p>That wasn't a BTC quirk. It refused all 424 dry-run $25 orders on testnet and all 468 on mainnet. The only orders that could pass were ones small enough that rounding didn't push them over, which made the "$10 to $25" range really "$10 to a bit under $25".</p>
<p>The fix is to check two different things. The <em>requested</em> amount must be between $10 and $25. The order's <em>own</em> notional must be at least $10 and no more than the request plus one size step (<code>10^-szDecimals x limit</code>). From the rebuilt version:</p>
<pre><code>$ python3 cli.py plan BTC buy 25
  "sz": 0.00035,
  "limit_px": 72876.0,
  "notional": 25.5066,
checks: pass
</code></pre>
<p>0.00035 x 72,876 = 25.5066. Over $25 by one step's worth of rounding, which is exactly what the cap now allows.</p>
<h2>2. Delisted coins looked tradable</h2>
<p><code>allMids</code> still quotes delisted perps. The original build treated "has a mid" as "trades", so:</p>
<pre><code>$ python3 cli.py plan MATIC buy 10
...
checks: pass
</code></pre>
<p>MATIC is one of 54 delisted perps on testnet. The <code>meta</code> response marks them with <code>"isDelisted": true</code>, and the asset lookup now raises on that flag:</p>
<pre><code>$ python3 cli.py plan MATIC buy 10
error: MATIC is delisted on testnet
</code></pre>
<p>Across three cycles of the rebuilt version, testnet listed 212 perps and mainnet 234. Every live one passed at both $10 and $25 on both sides (316/316 testnet, 356/356 mainnet), and every delisted one was refused:</p>
<pre><code>testnet 212 perps: {'pass_10': 316, 'pass_25': 316, 'refused_delisted': 216} []
mainnet 234 perps: {'pass_10': 356, 'pass_25': 356, 'refused_delisted': 224} []
</code></pre>
<h2>3. The dead-man switch would have blocked every order on a new account</h2>
<p>The prompt has the bot call <code>scheduleCancel</code> before every order: a dead-man switch that cancels all open orders at a set time if the bot dies. The original rule was strict. If <code>scheduleCancel</code> didn't return ok, send no order.</p>
<p>The problem is that Hyperliquid only allows <code>scheduleCancel</code> above a trading-volume threshold. A <a href="https://blog.mathquant.com/2025/01/16/quantitative-practice-of-dex-exchanges-2-hyperliquid-user-guide.html">published reply</a> shows the error as <code>Cannot set scheduled cancel time until enough volume traded. Required: $1000000</code>. A brand-new testnet account will never clear that, so the strict rule meant a reader following the recipe could never place an order at all.</p>
<p>To be clear about what we saw: our throwaway key was never approved and the account was never funded, so the exchange stopped us earlier, before the volume check could come up. The <code>orders.jsonl</code> log from the run shows that refusal on every attempt:</p>
<pre><code>{"kind": "schedule_cancel", "req": {"time": 1792953489708}, "resp": {"status": "err", "response": "User or API Wallet 0x46b7… does not exist."}}
</code></pre>
<p>So the volume error itself is sourced, not observed, and the code path for it is tested offline only.</p>
<p>The new behavior keeps the safety without the dead end:</p>
<ul>
<li>On that exact volume error, print <code>dead-man switch unavailable for this account</code> and continue.</li>
<li>On any other non-ok reply, still refuse and send no order. That's what happened in our runs: <code>refusing: schedule_cancel failed, no order sent</code>.</li>
<li><code>place</code> now takes <code>--hold SECONDS</code> (10 to 300). If the order rests, the bot waits, then cancels it by oid in a <code>finally</code> block, so Ctrl-C during the hold still cancels. It then checks <code>openOrders</code> that the oid is gone and exits non-zero if it isn't.</li>
</ul>
<p>Without the exchange's dead-man switch, the bot is its own. That's a weaker guarantee, so the hold is short and the cancel is confirmed.</p>
<p>One more check from the same runs: <code>place</code> and <code>cancel-all</code> don't accept <code>--mainnet</code> at all. Passing it gets <code>cli.py: error: unrecognized arguments: --mainnet</code> before anything is signed.</p>
<h2>4. The main key was left on disk</h2>
<p>Hyperliquid lets a main account approve an "agent" (API wallet) key that signs on its behalf. The prompt's safety notes say to keep the main key away from the bot and give the bot only the agent key.</p>
<p>The original <code>approve_agent.py</code> loaded the main key from the same env file it then wrote the agent key to. So after a successful approval, the key you're supposed to keep away from the bot sat right next to it.</p>
<p>Now it reads the main key once with <code>getpass.getpass()</code>, never writes it anywhere, and saves only <code>HL_MODE=testnet</code>, <code>HL_AGENT_PRIVATE_KEY</code> and <code>HL_ACCOUNT_ADDRESS</code> (the main address) to <code>~/.config/hl-bot/.env</code> with <code>chmod 600</code>. It also saves only the agent key the SDK returns from <code>approve_agent</code>, and only if the result is ok. On our unfunded account it saved nothing:</p>
<pre><code>$ python3 approve_agent.py
approve failed, nothing saved: {'status': 'err', 'response': 'Must deposit before performing actions. User: 0xd40d…'}
</code></pre>
<h2>Already built it? The follow-up prompt</h2>
<p>If you built the starter from the earlier prompt, paste this into the same project:</p>
<pre><code class="language-text">Update my hl-starter:
1. asset_info(): raise on any coin whose meta entry has "isDelisted": true. allMids still returns a mid for delisted perps, so plan/place must not accept them.
2. check_order(): check the requested usd is between $10 and $25, and allow the order's own notional (size x limit) to be at most usd plus one size step (10^-szDecimals x limit). Rounding the size up means comparing the notional itself to $25 refuses every $25 order.
3. place(): Hyperliquid rejects schedule_cancel on accounts without enough traded volume ("Cannot set scheduled cancel time until enough volume traded..."). On that exact error, print "dead-man switch unavailable for this account" and continue; on any other schedule_cancel error, still send no order. Add `--hold SECONDS` (10-300, default 60): after the order rests, wait, then cancel it by oid in a `finally` (so Ctrl-C cancels too) and confirm with openOrders that it's gone; exit non-zero if it isn't.
4. approve_agent.py: read the main testnet key with getpass.getpass() and never write it to any file. Write only HL_MODE=testnet, HL_AGENT_PRIVATE_KEY and HL_ACCOUNT_ADDRESS, keeping other lines in the env file.
Add a test for each, then run pytest and `python3 cli.py plan BTC buy 25`.
</code></pre>
<h2>Run it yourself</h2>
<p>The full prompt is on the <a href="https://botcookery.com/recipes/hyperliquid-api-starter-testnet/">recipe page</a>. If you'd rather start from code, the rebuilt starter is on GitHub at <a href="https://github.com/lazyclaw137-ship-it/hyperliquid-api-starter-testnet">lazyclaw137-ship-it/hyperliquid-api-starter-testnet</a>. Reads need nothing:</p>
<pre><code>pip install hyperliquid-python-sdk requests python-dotenv pytest
python3 cli.py mids
python3 cli.py book BTC --mainnet
python3 cli.py plan BTC buy 25
</code></pre>
<p>Signed calls need a testnet account and an approved agent key, and stay on testnet. The code checks for that twice.</p>
<p><strong>Verified working on live data (read-only), Oct 25, 2026.</strong> Reads ran live on testnet and mainnet, and order sizing was checked against every listed perp on both. Signed testnet calls used an unfunded throwaway key, so the exchange refused them before any order existed. We did not see a resting order, a fill or the dead-man volume error ourselves; the code path for that error is tested offline only.</p>
<p>More recipes, including a <a href="https://botcookery.com/recipes/hyperliquid-funding-rate-monitor/">Hyperliquid funding-rate monitor</a>, are at <a href="https://botcookery.com/">botcookery.com</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Our Polymarket Order Book Check Flagged 342 Mismatches. The Books Were Fine.]]></title><description><![CDATA[A read-only Python streamer for Polymarket's public market WebSocket, the four bugs live data found (sentinel prices, same-timestamp sweeps, a filter that…]]></description><link>https://botcookery.hashnode.dev/polymarket-websocket-order-book-342-mismatches</link><guid isPermaLink="true">https://botcookery.hashnode.dev/polymarket-websocket-order-book-342-mismatches</guid><category><![CDATA[Python]]></category><category><![CDATA[websockets]]></category><category><![CDATA[api]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[BotCookery]]></dc:creator><pubDate>Tue, 27 Oct 2026 14:05:37 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on <a href="https://botcookery.com/recipes/polymarket-websocket-starter/">BotCookery</a>, a free site of build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every number below comes from real runs on public Polymarket data on Oct 25, 2026. The streamer is read-only, and nothing here is financial advice or a trading signal.</em></p>
<p>Polymarket's market WebSocket hands you something most feeds don't: the server's own best bid and best ask on every single update. That makes it easy to prove your local order book is correct. Compare, and if they disagree, your book is wrong.</p>
<p>That's what our streamer did. During three 150-second runs on live data, it flagged 342 mismatches and tore down all 10 books 37 times. The books were fine. The check was wrong, twice over. And the market list it was streaming was missing the busiest game of the afternoon.</p>
<p>Some background: we publish copy-paste prompts that have an AI coding tool build a bot. A prompt is only as good as the code it produces, so on Oct 25 we did what a reader would do. We pasted our own Polymarket WebSocket prompt (first published Oct 23) into a clean folder, let the AI build it, 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. Subscribing, the heartbeat, list and object frames, aggregate sizes and the reconnect all worked. Here's what didn't, and how the prompt changed.</p>
<h2>What the market channel sends</h2>
<p>The short version, for anyone who hasn't used it:</p>
<ul>
<li>URL <code>wss://ws-subscriptions-clob.polymarket.com/ws/market</code>, no auth. Subscribe with <code>{"assets_ids":[...],"type":"market"}</code>, using the outcome token IDs from Gamma's <code>clobTokenIds</code>.</li>
<li>Send the text frame <code>PING</code> every 10 seconds. The server answers with the text <code>PONG</code>. Don't <code>json.loads</code> it.</li>
<li><code>book</code> is a full snapshot (sent on subscribe and after trades). A <code>price_change</code> entry carries the <strong>new aggregate size</strong> at a price, not a delta, and <code>"0"</code> removes the level. Each entry also carries the server's <code>best_bid</code> and <code>best_ask</code>.</li>
<li>The first dump arrives as a JSON list, later frames as single objects.</li>
</ul>
<p>That <code>best_bid</code>/<code>best_ask</code> 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.</p>
<h2>1. One-sided books failed every check</h2>
<p>When a side of the book is empty, the server doesn't leave <code>best_ask</code> blank. It sends <code>"1"</code>. An empty bid side comes through as <code>best_bid</code> <code>"0"</code>.</p>
<p>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 <code>"1"</code>. Every update on that market counted as a mismatch.</p>
<p>The fix is one line of intent: read <code>best_bid "0"</code> and <code>best_ask "1"</code> as an empty side before comparing.</p>
<h2>2. A single sweep looked like a broken book</h2>
<p>A taker selling through three bid levels doesn't arrive as one message. It arrives as three <code>price_change</code> messages <strong>with the same timestamp</strong>. Each one already carries the final, post-match best bid, while the level removals trickle in one message at a time.</p>
<p>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.</p>
<p>This is the first 150-second run of the original build:</p>
<pre><code>=== 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
</code></pre>
<p>Fourteen reconnects in 150 seconds, one of them deliberate. Across the three runs, the two bugs together caused <strong>342 mismatches in about 8,400 checks and 37 needless resyncs in 7.5 minutes</strong>, on top of the 3 forced reconnects.</p>
<p>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 <code>price_change</code> for that asset arrives with a <strong>different</strong> 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 <strong>0 mismatches in 7,266 checks</strong>.</p>
<h2>3. <code>--top</code> hid the busiest markets</h2>
<p>The Oct 23 prompt filtered Gamma's market list with <code>end_date_min</code>, to skip markets that had already ended. Reasonable, except sports markets use the <strong>kickoff time</strong> as <code>endDate</code>. The filter dropped every game in progress.</p>
<p>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.</p>
<p>The filter is gone. Instead, <code>--top</code> 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:</p>
<pre><code>skip lol-hle1-ig1-2026-10-25: decided (bid None ask 0.001)
streaming 10 outcomes
</code></pre>
<h2>4. The audit froze the book it was auditing</h2>
<p>Every 40 seconds the streamer compares each local book with the REST <code>/book</code> endpoint. If they differed, the original version waited 2 seconds and re-checked. The wait was a <code>sleep</code> inside the receive loop, so while it slept the local book stopped updating and REST moved on without it.</p>
<p>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:</p>
<pre><code>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
</code></pre>
<p>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.</p>
<p>The fix uses a field that was there all along: the REST book has a <code>timestamp</code> 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 <strong>DIFF</strong> only when the local book has already seen everything REST had and still disagrees. If REST stays ahead, the audit reports <strong>MOVED</strong>:</p>
<pre><code>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)
</code></pre>
<p>That's not an error: the book changed faster than one REST call.</p>
<h2>The rebuilt version</h2>
<p>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:</p>
<pre><code>$ 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
</code></pre>
<p>Across the three runs: <strong>34,167 <code>price_change</code> events, 54,326 server checks, 0 mismatches.</strong> 3 reconnects, all of them the forced ones, and 258 trades. The REST audits came out <strong>89 OK, 1 MOVED, 0 DIFF</strong>. One EPL match ended during the runs: both of its books went one-sided at 0.999 and <code>tick_size_change</code> events arrived, without a single mismatch. The 18 offline tests pass (fake socket, fake clock, no network).</p>
<h2>Smaller things worth knowing</h2>
<ul>
<li><strong><code>new_market</code> and <code>market_resolved</code> are global.</strong> With <code>custom_feature_enabled: true</code> 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 <code>new_market: 499</code> in the stats line above for a 10-outcome subscription. Count and log them; don't print them.</li>
<li><strong>REST books are sorted the "wrong" way for <code>[0]</code>.</strong> Bids come ascending and asks descending, so best bid is the max price and best ask is the min. Never take element <code>[0]</code>.</li>
<li><strong>Normalise price keys.</strong> <code>"0.5"</code> and <code>"0.50"</code> are the same level. Key the book by a <code>Decimal</code>-normalised string.</li>
<li><strong>One connection is enough.</strong> Polymarket's changelog says the 100-token limit on the market channel was removed on May 28, 2025.</li>
</ul>
<h2>The prompt</h2>
<p>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 <code>python3 -m pytest -q</code> before touching the network.</p>
<pre><code class="language-text">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 &gt; Real-Time Data;
API Reference &gt; WebSocket &gt; 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=&lt;slug&gt; 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&amp;order=volume24hr
    &amp;ascending=false&amp;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, &lt;= 0.01 or &gt;= 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=&lt;id&gt;. 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 -&gt; bids, SELL -&gt; 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 -&gt; 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 &lt;slug&gt; [&lt;outcome&gt;] &lt;side&gt; &lt;size&gt; @ &lt;price&gt;" (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 &lt;= 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 &lt;= 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.
</code></pre>
<p>Then:</p>
<pre><code class="language-bash">python3 -m venv .venv &amp;&amp; source .venv/bin/activate
pip install requests websocket-client pytest
python3 -m pytest -q
python3 pmws.py book --slug &lt;market-slug&gt;
python3 pmws.py stream --top 5 --duration 150 --print-every 30 --audit-every 40 --drop-after 60 --custom --log events.jsonl
</code></pre>
<p>In the final stats line, pongs should equal pings (or trail by one), reconnects should be 1, and <code>server_mismatches</code> and <code>audit_diff</code> should be 0. No account or key is needed.</p>
<h3>Already built an earlier version?</h3>
<p>Paste this into the same project:</p>
<pre><code class="language-text">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 -&gt; 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 &lt;= 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, &lt;= 0.01 or &gt;= 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.
</code></pre>
<h2>What this is and isn't</h2>
<p>The streamer only reads. It never connects to the authenticated user channel, holds no key, and a test fails if any app module mentions <code>private_key</code>, <code>eth_account</code>, <code>ws/user</code>, <code>post_order</code>, <code>create_order</code>, <code>/order</code> or <code>api_secret</code>. 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.</p>
<p>The full recipe, with step-by-step setup, the FAQ and links to every Polymarket doc we checked, is at <strong><a href="https://botcookery.com/recipes/polymarket-websocket-starter/">botcookery.com/recipes/polymarket-websocket-starter</a></strong>. For REST books, fees, L1/L2 auth and a paper broker on the same CLOB, see the <a href="https://botcookery.com/recipes/polymarket-clob-api-starter/">Polymarket CLOB API starter</a>.</p>
]]></content:encoded></item><item><title><![CDATA[We Rebuilt Our Hyperliquid Whale Tracker From Its Own Prompt. The Biggest Trade Was Invisible.]]></title><description><![CDATA[A read-only Python tracker for Hyperliquid's public trade feed, what broke on live mainnet data (a ~$417k TWAP nobody flagged), and the fixed AI prompt]]></description><link>https://botcookery.hashnode.dev/hyperliquid-whale-tracker-twap-blind-spot</link><guid isPermaLink="true">https://botcookery.hashnode.dev/hyperliquid-whale-tracker-twap-blind-spot</guid><category><![CDATA[Python]]></category><category><![CDATA[websockets]]></category><category><![CDATA[AI]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[Blockchain]]></category><dc:creator><![CDATA[BotCookery]]></dc:creator><pubDate>Mon, 26 Oct 2026 14:03:41 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on <a href="https://botcookery.com/recipes/hyperliquid-whale-tracker/">BotCookery</a>, a free site of build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every number below comes from a real run on public Hyperliquid data on Oct 24, 2026. Nothing here is financial advice or a trading signal.</em></p>
<p>We publish copy-paste prompts that have an AI coding tool build a bot. A prompt is only as good as the code it produces, so on Oct 24 we did what a reader would do: pasted our own Hyperliquid "whale tracker" prompt into an empty folder, let the AI build it, and ran it against live mainnet data.</p>
<p>The tests passed. The websocket stayed up. Every API claim on the page checked out. And during a 3-minute stream, the biggest flow in the window never set off an alert.</p>
<h2>What the tracker does</h2>
<p>Hyperliquid runs its order book on-chain, so every trade and every account's positions are public, with no API key. The tracker only reads:</p>
<ul>
<li><strong>Large trades, live:</strong> it streams the public <code>trades</code> websocket, groups the fills that belong to one taker order, and flags orders whose USD notional (size × price) is above a threshold.</li>
<li><strong>Public positions:</strong> <code>clearinghouseState</code> returns any address's perp positions, entry price, unrealized PnL and liquidation price. It watches up to 10 addresses (we used protocol vaults, not individuals) and reports opens, closes and flips.</li>
<li><strong>Recent fills:</strong> <code>userFills</code>, capped at the 2,000 most recent.</li>
</ul>
<p>It has no key, no signing library and no order code, and a test fails the build if one sneaks in. It also has no "follow" or "copy" feature. A large trade can be a hedge, a liquidation or one leg of something elsewhere, and the tape doesn't say which.</p>
<h2>What held up on live data</h2>
<ul>
<li>The first <code>trades</code> message after subscribing was 30 <strong>old</strong> trades (the oldest about a minute old) with no snapshot flag. Skip it, and skip it again after every reconnect, or you alert on history.</li>
<li>One order is many trades. A $1.51M HYPE sell arrived as <strong>420</strong> separate trades. Group by order <code>hash</code> before you apply a threshold.</li>
<li><code>side</code> is the <strong>taker</strong> side. <code>users</code> is always <code>[buyer, seller]</code>, so the taker is <code>users[0]</code> on <code>B</code> and <code>users[1]</code> on <code>A</code>. We confirmed it against <code>userFills</code> (<code>crossed: true</code>).</li>
<li>With a ping every 50 seconds, a 180-second stream had 0 reconnects. The server closes the socket after 60 seconds without a message from you.</li>
<li>The HLP parent vault showed $37.1M equity and <strong>no</strong> positions. The positions live on its child accounts (<code>vaultDetails</code> → <code>childAddresses</code>); two of the seven held 177 each.</li>
<li><code>userFills</code> stopped at exactly 2,000 fills.</li>
</ul>
<h2>The bug: a ~$417k TWAP that never crossed the line</h2>
<p>TWAP orders on Hyperliquid execute as many small slices, and every slice carries an all-zero <code>hash</code>. Our original prompt correctly said "never merge zero-hash trades into one order group", then applied the dollar threshold to each slice on its own.</p>
<p>At a $100k threshold, the 3-minute stream flagged nothing. We dropped the threshold to $10k to see what we were missing: 14 of the 22 alerts were single TWAP slices from one address, <code>0xbda2…75db</code>. Added up, they came to <strong>about $417k of BTC bought in two minutes</strong>, and no single slice was over $67.5k. We checked with <code>userTwapSliceFills</code>: they were that address's own TWAP, on the taker side.</p>
<p>So the tool had two failure modes and no good setting: at a useful threshold the largest flow was invisible, and at a low one it printed a line per slice.</p>
<p><strong>The fix</strong> is a small <code>TwapTracker</code>: a rolling 5-minute sum of TWAP slice notional per (coin, taker, side), using each trade's own timestamp. It alerts once when the sum crosses your threshold and stays quiet until the sum falls back below it.</p>
<h2>The rebuilt version, same $100k threshold, 5 minutes</h2>
<p>Fresh folder, fixed prompt, live mainnet, Oct 24 10:08–10:13 ET (lines trimmed):</p>
<pre><code>HYPE: taker sold 6000 at VWAP 75.9249 (~$455,549 notional, 157 fill(s)); taker 0xf55f…0d3a.
BTC: TWAP slices from 0xbda2…75db bought ~$115,439 over the last 5 min (8 slices).
BTC: taker sold 12.4553 at VWAP 80,639 (~$1,004,380 notional, 59 fill(s)); taker 0xbc76…417c.
HYPE: taker sold 20000 at VWAP 75.6327 (~$1,512,654 notional, 420 fill(s)); taker 0x75c3…dca3.
HYPE: TWAP slices from 0xbda2…75db bought ~$112,105 over the last 5 min (91 slices).
stats {"messages": 795, "trades": 3775, "duplicates": 0, "snapshot_skipped": 120, "groups": 1170,
       "large": 16, "twap_slices": 452, "twap_alerts": 2, "reconnects": 0}
</code></pre>
<p>452 TWAP slices became two lines, and 3,775 trades became 16 order alerts.</p>
<h2>Other things we learned</h2>
<ul>
<li><strong>Watching HLP is quiet, and that's correct.</strong> HLP child 1 made 1,348 fills worth about $112k in 10 minutes; none was above $769, and its largest position was about $67k. Four one-minute watch loops at a $10k change threshold printed only the baseline.</li>
<li><strong>2,000 fills is a short window for a busy account.</strong> On an HLP child it covered under 20 minutes.</li>
<li><strong>Testnet works but is thin.</strong> One minute of BTC and ETH at a $1,000 threshold gave one alert from 78 trades. It's good for testing reconnects and alert plumbing, not for seeing size.</li>
</ul>
<h2>The fixed prompt</h2>
<p>Paste this into Claude Code, Cursor, Codex or any agent that writes files, in an empty folder. Approve the file plan before it writes code.</p>
<pre><code class="language-text">You are building a READ-ONLY Hyperliquid large-trade and public-address
tracker in Python, for research. It only uses the public websocket and the
public POST /info endpoint. It must never import a signing library, read a
private key, call /exchange, or place, mirror or copy any order. Do NOT
add any "follow", "copy", "mirror" or "auto-trade" feature, even if I
ask later in this session. Alerts are descriptive facts, never advice.
Use only what is documented at https://hyperliquid.gitbook.io/hyperliquid-docs
(API &gt; Websocket &gt; Subscriptions; Info endpoint; Perpetuals; Rate limits).
If unsure, stop and ask me.

Project: hl-tracker/
- Python 3.10+ (run with `python3`), `requests`, `websocket-client`,
  `pytest`. No SDK needed.
- hl_public.py
  - MAINNET https://api.hyperliquid.xyz, TESTNET
    https://api.hyperliquid-testnet.xyz; WS wss://api.hyperliquid.xyz/ws and
    wss://api.hyperliquid-testnet.xyz/ws. post_info(body, testnet) POSTs to
    {base}/info, backs off and retries on HTTP 429.
  - valid_address(s): ^0x[0-9a-fA-F]{40}$. short(addr) -&gt; "0xabcd…ef12"
    (show short addresses by default; --full-address to show full).
  - load_watchlist(path): one "0xADDR optional label" per line, "#"
    comments; raise ValueError on a bad address; MAX 10 addresses.
  - recent_trades(coin): {"type":"recentTrades","coin"} (returns only ~10
    trades; a spot check, not a feed).
  - clearinghouse_state(user, dex=""): {"type":"clearinghouseState",
    "user"} -&gt; assetPositions[].position {coin, szi (signed size), entryPx,
    positionValue, unrealizedPnl, leverage{type,value}, liquidationPx
    (can be null), marginUsed}, marginSummary {accountValue, totalNtlPos}.
    Weight 2.
  - user_fills(user, aggregate=True): {"type":"userFills","user",
    "aggregateByTime"} -&gt; at most the 2000 most recent fills {coin, px, sz,
    side, time, dir, closedPnl, hash, crossed (true = taker), fee, tid}.
    An agent (API) wallet address returns empty data; use the main account.
  - vault_children(vault): {"type":"vaultDetails","vaultAddress"} -&gt;
    relationship {type:"parent", data:{childAddresses:[...]}}. A parent
    vault like HLP shows equity in clearinghouseState but EMPTY
    assetPositions; the positions live on the child addresses. Add a
    `--vault ADDR` option to positions/watch that expands to the children
    (still capped at 10).
- large_trades.py
  - WsTrade = {coin, side, px, sz, hash, time, tid, users:[buyer, seller]}.
    side is the TAKER side: taker = users[0] if side == "B" else users[1].
    notional = float(px) * float(sz).
  - Deduper keyed on (time, coin, tid) (the documented unique key), bounded
    (e.g. OrderedDict capped at 50k).
  - aggregate(trades): group by (coin, hash, side, taker); sum sz and
    notional, VWAP, fill count. A hash of all zeros marks TWAP fills: NEVER
    merge those into one order group (each slice is its own trade). Return
    them separately, flagged twap=True.
  - TwapTracker(window_s=300, min_usd): TWAP slices are usually far below
    any useful threshold one by one, but one TWAP can move a lot in a few
    minutes. Keep a rolling sum of TWAP slice notional per (coin, taker,
    side) over window_s, using the trades' own time (ms). Alert ONCE when
    the sum crosses min_usd; re-arm only after it drops back below. Never
    alert on a single TWAP slice by itself.
  - large(groups, min_usd) (non-TWAP groups only); describe(g): "BTC: taker
    sold 5.39 at VWAP 81,261 (~$438,696 notional, 19 fill(s)); taker 0x….
    Informational only, not a signal." describe_twap: "BTC: TWAP slices
    from 0x… bought ~$416,667 over the last 5 min (14 slices).
    Informational only, not a signal." No buy/sell suggestions, no "smart money" language.
- ws_trades.py
  - TradeHandler(min_usd, on_large, include_snapshot=False): ignore
    channels other than "trades" (subscriptionResponse, pong). The FIRST
    trades message per coin on each connection is a snapshot of ~30 OLD
    trades and is NOT tagged isSnapshot: skip it unless include_snapshot,
    and reset that state on every reconnect. Dedupe, aggregate per
    message, call on_large for non-TWAP groups &gt;= min_usd, feed TWAP
    slices to a TwapTracker (same min_usd) and call on_large for its
    alerts, keep stats (including twap_slices and twap_alerts).
  - run(url, coins, handler, duration=None, ping_every=50, connect=None):
    subscribe {"method":"subscribe","subscription":{"type":"trades",
    "coin":C}} per coin; send {"method":"ping"} every 50s (the server
    closes after 60s without a message from you). On EVERY (re)connect set
    last_ping = float("-inf") (not 0: with a monotonic or test clock, 0
    can delay the first ping), so the first ping goes out right after
    subscribing instead of being delayed past the 60s server timeout; on any drop, reconnect
    with exponential backoff capped at 60s and resubscribe. `connect` is
    injectable for tests. Limits: 10 WS connections/IP, 1000 subscriptions,
    2000 messages/min; use ONE connection for all coins.
- positions.py
  - parse_positions(state) -&gt; {coin: {szi, entryPx, positionValue,
    unrealizedPnl, leverage, liquidationPx}} skipping szi == 0;
    account_summary(state).
  - diff(prev, cur, min_change_usd) -&gt; events opened / closed / flipped /
    increased / decreased. EVERY event type must respect min_change_usd
    (for a flip, use max(old, new) notional) or dust flips spam you.
  - describe_event: "HLP child A: opened short ETH ~$120,000. Public data,
    informational only, not a signal."
- alerts.py: Alerter(webhook_url=None, log_path=None). Always print; if
  log_path, append JSONL; if webhook, POST {"text": msg, "content": msg}.
  Wrap the POST in `except Exception`, log "webhook error (continuing)",
  never crash.
- tracker.py (argparse; --testnet and --full-address on EACH subcommand;
  print a banner "Public Hyperliquid data. Informational and research use
  only; not advice and not a signal."):
  - trades --coins BTC,ETH,SOL --min-usd 1000000 [--twap-window 300] [--duration S]
    [--webhook URL or env HLW_WEBHOOK_URL] [--log trades.jsonl]
    [--include-snapshot]
  - recent --coins BTC,ETH --min-usd 250000
  - positions ADDR... | --watchlist FILE [--top 10]
  - watch ADDR... | --watchlist FILE [--interval 60 (min 30)]
    [--min-change-usd 100000] [--max-loops N] [--webhook] [--log].
    The first read is a silent baseline; only later changes alert.
  - fills ADDR [--min-usd 100000] [--last 20]
- tests/conftest.py inserting the project root into sys.path.
- tests/ (pytest, fake sessions and fake websockets, no network):
  notional + taker side, TWAP slices summed per taker and alerted once
  when the rolling sum crosses (and not per slice), (threshold fixtures must compute notional the
  SAME way as the code, float(px) * float(sz), and sit clearly above or
  below the threshold), multi-fill aggregation, zero-hash not merged,
  dedupe, threshold, snapshot skipped + reset on reconnect, control
  messages ignored, run() subscribes/pings/reconnects, address validation
  + 10-address cap, position diff incl. a small flip below threshold
  ignored, vault_children parsing, first ping sent immediately after each
  connect, neutral wording (no "buy now", "follow", "copy"), testnet URLs,
  429 backoff, webhook that raises, and a grep test that the APP MODULES
  (not tests/) contain none of "/exchange", "private_key", "eth_account",
  "Exchange(", "place_order", "market_open", "auto_follow", "copy_trade".
- .gitignore (.env, __pycache__/, .venv/, *.jsonl, watchlist.txt), README
  with the commands and the informational-only notice.

Show me the file plan first. After writing, tell me exactly what to run.
</code></pre>
<p>Then run it:</p>
<pre><code class="language-bash">python3 -m venv .venv &amp;&amp; source .venv/bin/activate
pip install requests websocket-client pytest
python3 -m pytest -q
python3 tracker.py trades --coins BTC,ETH,SOL,HYPE --min-usd 100000 --duration 180 --log trades.jsonl
</code></pre>
<p>The final stats line should show <code>snapshot_skipped</code> &gt; 0 and multi-fill orders grouped into one line. A quiet market can give no alerts at all.</p>
<h2>Already built one from an older prompt?</h2>
<p>Paste this into the same project:</p>
<pre><code class="language-text">Update my Hyperliquid tracker for TWAP fills:
1. Keep zero-hash trades as separate twap=True groups, but don't send them
   to the large-trade check one by one.
2. Add TwapTracker(min_usd, window_s=300): a rolling sum of TWAP slice
   notional per (coin, taker, side), using each trade's own time (ms).
   Alert ONCE when the sum crosses min_usd; re-arm after it drops below.
   Line: "BTC: TWAP slices from 0x… bought ~$416,667 over the last 5 min
   (14 slices). Informational only, not a signal."
3. TradeHandler feeds TWAP slices to the tracker (same min_usd) and sends its
   alerts to the same alert output. Add twap_slices and twap_alerts to stats.
4. Add --twap-window (seconds, default 300) to the trades command.
Add tests: slices summed and alerted once (not per slice), the window rolls
off and re-arms, and large() ignores TWAP groups. Run them and show me the
diff before changing anything else. Still no keys, orders or copy features.
</code></pre>
<h2>Safety and scope</h2>
<ul>
<li>Read-only: no key, no wallet, no <code>/exchange</code> calls, enforced by a test.</li>
<li>No copy trading, no auto-follow. If an AI tool offers to "mirror" an address, say no.</li>
<li>Public doesn't mean fair game: short addresses by default, a 10-address cap, and protocol vaults as examples. Don't try to tie addresses to real people.</li>
<li>Respect the limits: one websocket connection, ping every 50 seconds, back off on reconnect, and poll positions no faster than every 30 seconds.</li>
</ul>
<p>The full recipe, with steps, FAQ and links to the official Hyperliquid docs, is at <a href="https://botcookery.com/recipes/hyperliquid-whale-tracker/">botcookery.com/recipes/hyperliquid-whale-tracker</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Build a Polymarket Bot with Claude Code (Paper Mode + Guardrails)]]></title><description><![CDATA[A Polymarket US bot that runs in paper mode by default, with a local paper ledger, order preview, hard limits and a kill switch.]]></description><link>https://botcookery.hashnode.dev/polymarket-bot-claude-code-paper-mode</link><guid isPermaLink="true">https://botcookery.hashnode.dev/polymarket-bot-claude-code-paper-mode</guid><category><![CDATA[Python]]></category><category><![CDATA[AI]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[BotCookery]]></dc:creator><pubDate>Fri, 23 Oct 2026 17:32:30 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on <a href="https://botcookery.com/recipes/polymarket-bot-claude-code-paper-mode/">BotCookery</a>, a free site of build recipes for AI-built trading and prediction-market bots. Written with AI assistance and checked against the Polymarket US docs linked below. Nothing here is financial advice, and there's no strategy in it that makes money.</em></p>
<p><strong>Verified working on live data in paper mode, Oct 22, 2026.</strong> Real orders need your own keys.</p>
<p>You can build a Polymarket US bot with Claude Code by having it write a bot that runs in paper mode by default: it simulates fills locally against the live public order book and can validate orders with <code>POST /v1/order/preview</code> without placing them. Polymarket US has no public demo exchange, so a local paper ledger, hard position limits, a kill switch and a locked live mode are the safe way to start.</p>
<p><strong>Goal:</strong> A Polymarket US bot skeleton that watches markets you choose, runs your rule, and records simulated fills in a local paper ledger. A live mode exists but is locked behind a flag, a typed confirmation and hard limits.</p>
<p><strong>Who it's for:</strong> Builders who want a real bot structure (data → rule → risk check → order) without risking money on day one.</p>
<p><strong>Not for:</strong> Anyone looking for a strategy that makes money. We don't provide one, and nobody honest can promise one.</p>
<h2>At a glance</h2>
<ul>
<li>Polymarket US has <strong>no public demo exchange</strong> for retail API users, so paper mode here means: simulate fills locally against the live public order book, and optionally call <code>POST /v1/order/preview</code> (body wrapped as <code>{"request": order}</code>) to validate an order without placing it.</li>
<li>Market data comes from <code>https://gateway.polymarket.us</code> with no key. Live orders go to <code>POST https://api.polymarket.us/v1/orders</code>.</li>
<li>Order fields: <code>marketSlug</code>, <code>intent</code> (<code>ORDER_INTENT_BUY_LONG</code>, <code>ORDER_INTENT_SELL_LONG</code>, <code>ORDER_INTENT_BUY_SHORT</code>, <code>ORDER_INTENT_SELL_SHORT</code>), <code>type</code> (<code>ORDER_TYPE_LIMIT</code>/<code>ORDER_TYPE_MARKET</code>), <code>price: {value, currency: "USD"}</code>, <code>quantity</code>, <code>tif</code> (e.g. <code>TIME_IN_FORCE_IMMEDIATE_OR_CANCEL</code>), and <code>manualOrderIndicator</code>, which the docs say is required (<code>MANUAL_ORDER_INDICATOR_AUTOMATIC</code> for a bot).</li>
<li>There's <strong>no client order id</strong> on the order API, so idempotency has to be local: record each order before sending and never auto-retry <code>POST /v1/orders</code> after a timeout.</li>
<li><code>price.value</code> always refers to the YES (long) side, even when buying NO, and must be between 0.01 and 0.99. An out-of-range order still gets an order ID but never fills.</li>
<li>Read each market's <code>orderPriceMinTickSize</code> and <code>minimumTradeQty</code> before ordering. Don't assume them.</li>
<li>Cancel everything: <code>POST /v1/orders/open/cancel</code>.</li>
</ul>
<h2>The prompt</h2>
<p>Paste this into Claude Code (or Cursor, or Codex) in an empty folder:</p>
<pre><code class="language-text">Build a Polymarket US trading bot in Python 3.11+ (requests, cryptography,
pytest only) that runs in PAPER MODE by default. Use only endpoints documented
at https://docs.polymarket.us. If you are unsure an endpoint or field exists,
ask me instead of guessing.

Read CLAUDE.md (the BotCookery safety rules) first and follow it. If you are
not allowed to write under .claude/, tell me and I will approve it; do not
work around it.

Architecture (one file per module):
- config.py   : loads config.toml (markets = list of slugs, poll_seconds,
                target_price) and limits.json (max_contracts_per_order,
                max_open_exposure_usd, max_daily_loss_usd,
                max_orders_per_minute). Fail closed if a limit is missing.
- data.py     : public market data from https://gateway.polymarket.us, no keys:
                GET /v1/markets?active=true&amp;closed=false&amp;limit=20 (listing),
                GET /v1/market/slug/{slug} (response {"market": {...}}; read
                orderPriceMinTickSize and minimumTradeQty; cache 5 min),
                GET /v1/markets/{slug}/book (response
                {"marketData": {"bids":[{"px":{"value"},"qty"}],"offers":[...],
                "state"}}; px.value and qty are strings; prices are YES-side),
                GET /v1/markets/{slug}/bbo.
                Bounded exponential backoff on 429/5xx.
- strategy.py : ONE placeholder rule I will replace: "if best YES offer &lt;=
                target_price from config, propose buying the minimum quantity
                of YES". Return an order body dict or None, plus a one-line
                reason. No ML, no "AI picks".
- risk.py     : check_order() enforcing every limit in limits.json plus: price
                within 0.01-0.99 AND on the market's tick, quantity a multiple
                of minimumTradeQty, no YES+NO buys on the same market
                (self-match), and a kill switch (file KILL exists or BOT_KILL=1
                =&gt; no orders). Unit-test every rule.
- paper.py    : local ledger (paper_ledger.json). Fill conservatively against
                the current book: buys lift offers at or below my limit, sells
                hit bids at or above it, at the displayed price, never more
                than displayed size, walking levels. NO intents trade the
                inverse (BUY_SHORT fills against YES bids at cost 1 - bid).
                Taker fee = theta * qty * p * (1-p), theta = the market's
                feeCoefficient (0.0695 standard; Table Tennis 0.10, see
                https://docs.polymarket.us/fees), rounded to $0.01 per fill
                with banker's rounding (round half to even), total for one
                order capped at the rounded cumulative fee. Test against
                the fee page's worked examples. Track cash, positions, realized
                and unrealized PnL (mark at the bid).
- broker.py   : LIVE adapter for https://api.polymarket.us. Ed25519: the secret
                key from env is base64; the first 32 decoded bytes are the
                seed. Headers X-PM-Access-Key, X-PM-Timestamp (ms, within 30 s
                of server time), X-PM-Signature = base64(sign(timestamp +
                METHOD + path)), e.g. path "/v1/portfolio/positions"; the body
                is not signed. Methods: preview_order (POST /v1/order/preview,
                body wrapped as {"request": order}), create_order
                (POST /v1/orders), cancel_all (POST /v1/orders/open/cancel,
                body {"slugs": []}), positions (GET /v1/portfolio/positions),
                balances (GET /v1/account/balances).
                Order body: marketSlug, type ORDER_TYPE_LIMIT, price
                {value, currency "USD"} (ALWAYS the YES price: buying NO at
                0.83 sends 0.17), quantity (number), tif
                TIME_IN_FORCE_IMMEDIATE_OR_CANCEL, intent, and
                manualOrderIndicator MANUAL_ORDER_INDICATOR_AUTOMATIC.
- bot.py      : main loop. Default --paper. --preview calls the preview
                endpoint with my key but never places orders (exit with a
                clear message if keys are missing). --live requires the flag,
                keys, AND typing "I accept the risk"; refuse if stdin is not a
                terminal. Every loop: kill-switch check -&gt; data -&gt; strategy -&gt;
                risk -&gt; paper/preview/live -&gt; log to logs/decisions.jsonl.
- cancel_all.py : one command that calls cancel-all in live mode.

Rules:
- Keys only from env vars (POLYMARKET_KEY_ID, POLYMARKET_SECRET_KEY).
  .env.example with blanks; .env in .gitignore; never print keys.
- Bounded exponential backoff on 429/5xx for reads and cancels. A "Global Rate
  Limit Exceeded" reject on an order is a latency stopgap, not a rate limit:
  log it, don't retry.
- The order API has no client order id, so do idempotency locally: record a
  key (hash of order + nonce) in sent_orders.json BEFORE sending, never
  re-send a recorded key, and never auto-retry POST /v1/orders after a
  timeout or 5xx (outcome unknown; check open orders instead).
- pytest tests for risk.py (each rule), paper.py fills (partial, NO side,
  sells, fee examples), the YES-price inversion, the live-mode gate, and
  broker signing (verify with the public key) using a fake HTTP session.

Show me the file plan first. Then build it. Then give me the exact commands
to run it in paper mode.
</code></pre>
<h2>Steps</h2>
<ol>
<li><strong>Set up the kitchen.</strong> New folder, <code>git init</code>, then add the <a href="https://botcookery.com/safety-pack/">Safety Pack</a>: save the rules file as <code>CLAUDE.md</code>, add the <code>.gitignore</code>, and add <code>.claude/settings.json</code> with the deny rules so Claude Code can't read your <code>.env</code> or key files. Claude Code will ask permission before writing under <code>.claude/</code>; approve it.</li>
<li><strong>Start Claude Code</strong> in the folder and paste the prompt. Review the file plan before it writes code. If it proposes any endpoint you can't find on docs.polymarket.us, push back.</li>
<li><strong>Fill in <code>config.toml</code>.</strong> Pick 1–3 market slugs from <code>GET https://gateway.polymarket.us/v1/markets?active=true&amp;closed=false&amp;limit=20</code>. Without <code>closed=false</code>, the list can include markets that have already closed. Set a target price for the placeholder rule.</li>
<li><strong>Set tiny limits</strong> in <code>limits.json</code>, for example 1 contract per order, $5 max exposure, $2 max daily loss, 3 orders a minute.</li>
<li><strong>Run paper mode</strong> with <code>python bot.py --paper</code>. Watch <code>logs/decisions.jsonl</code>. Every line should show the data it saw, what the rule proposed, and whether risk said yes or no. Some markets quote below $0.01 (their tick can be 0.001); the risk check refuses those because the exchange only accepts 0.01–0.99, so a "price outside 0.01-0.99" line is the guardrail working, not a bug.</li>
<li><strong>Test the kill switch.</strong> While it's running, <code>touch KILL</code>. The next loop should log "kill switch on" and place nothing. Delete the file to resume.</li>
<li><strong>Replace the placeholder rule</strong> with your own logic. Keep it simple and explainable. The bot should be able to tell you in one sentence why it wanted each order.</li>
<li><strong>Optional preview mode.</strong> Once you have a verified Polymarket US account and API key, run <code>python bot.py --preview</code>. It calls <code>POST /v1/order/preview</code> with the order wrapped as <code>{"request": order}</code> to validate it and show expected fills, and it places nothing. Without keys, <code>--preview</code> should exit with a clear message.</li>
<li><strong>Only after days of clean paper logs</strong> would you even consider <code>--live</code>, with limits you're fine losing entirely. The typed confirmation is there to make you stop and think.</li>
</ol>
<h2>Paper mode, properly</h2>
<p><strong>Level 1: local paper ledger (no account needed).</strong> <code>paper.py</code> simulates fills against the real public book. Be conservative: fill at the ask for buys and the bid for sells, never better, and never more than the displayed size. Mark open positions at the bid, so a fresh position shows a loss equal to the spread. Real fills are often worse than paper fills because of latency, queue position and fees, so treat paper profits with suspicion.</p>
<p><strong>Fees belong in the simulation.</strong> The taker fee is <code>Θ × contracts × p × (1 − p)</code> with Θ = 0.0695 on standard markets, rounded to the cent with banker's rounding (round half to even). Have the AI write tests against the worked examples on the <a href="https://docs.polymarket.us/fees">fee schedule</a>; binary floating point can round half-cent cases the wrong way, so round with <code>Decimal</code> and <code>ROUND_HALF_EVEN</code>.</p>
<p><strong>Level 2: order preview (needs a key, places nothing).</strong> <code>POST /v1/order/preview</code> checks your parameters against the exchange (tick size, minimum quantity, price validity) and returns what it expects would happen. It's the best way to catch "valid in my head, rejected by the exchange" bugs before going live.</p>
<p><strong>What to review before you go further:</strong></p>
<ul>
<li>At least a week of <code>decisions.jsonl</code> with no surprise orders.</li>
<li>Every rejected order has a clear reason.</li>
<li>Paper PnL includes fees.</li>
</ul>
<h2>Safety notes</h2>
<ul>
<li><code>price.value</code> is always the YES side. Buying NO at 0.83 means sending <code>0.17</code>. Write a unit test for this; it's the most common way these bots buy the wrong thing.</li>
<li>Read <code>orderPriceMinTickSize</code> and <code>minimumTradeQty</code> from the market response. Don't hardcode them.</li>
<li>Send <code>manualOrderIndicator: MANUAL_ORDER_INDICATOR_AUTOMATIC</code> on every bot order. The docs mark the field as required.</li>
<li>Never auto-retry <code>POST /v1/orders</code> after a timeout or 5xx. There's no client order id, so a retry can double your order. Check open orders instead.</li>
<li>Don't place a YES buy and a NO buy at complementary prices on the same market. Polymarket US treats that as a self-match.</li>
<li>Prefer the markets WebSocket over polling the book. Polling burns your 25 req/s limit (shared per IP) fast.</li>
</ul>
<h2>FAQ</h2>
<h3>Can Claude Code build a Polymarket trading bot?</h3>
<p>Yes. Claude Code can write the whole structure (data, strategy, risk checks, broker adapter) from a good prompt. It can't make the strategy profitable, and it will make mistakes, so run it in paper mode and read the code, especially the parts that touch keys and orders.</p>
<h3>Does Polymarket US have a paper trading or demo mode?</h3>
<p>There's no public retail demo exchange documented. The practical approach is a local paper ledger that simulates fills against the live public order book, plus the <code>POST /v1/order/preview</code> endpoint, which validates an order and shows expected fills without placing it.</p>
<h3>Can I run a Polymarket bot from the US?</h3>
<p>US residents should use Polymarket US, the CFTC-regulated exchange, which requires identity verification before you can create API keys. International Polymarket is close-only for the US, and using a VPN to get around that breaks its terms.</p>
<h3>Should I buy a Polymarket bot instead of building one?</h3>
<p>Be careful. Many "bots for sale" are scams or repackaged public GitHub code, and any repo you run can hide code that steals keys. If you use someone else's code, audit it first. See our <a href="https://botcookery.com/recipes/audit-kalshi-polymarket-bot-github-repo/">GitHub audit recipe</a>.</p>
<h2>Docs</h2>
<ul>
<li><a href="https://docs.polymarket.us/api-reference/orders/overview">Polymarket US: Orders API overview</a></li>
<li><a href="https://docs.polymarket.us/api-reference/orders/preview-order">Polymarket US: Preview order</a></li>
<li><a href="https://docs.polymarket.us/api-reference/orders/cancel-all-open-orders">Polymarket US: Cancel all open orders</a></li>
<li><a href="https://docs.polymarket.us/api-reference/market/overview">Polymarket US: Markets API overview</a></li>
<li><a href="https://docs.polymarket.us/api-reference/authentication">Polymarket US: Authentication (Ed25519 signing)</a></li>
<li><a href="https://docs.polymarket.us/fees">Polymarket US: Fee schedule</a></li>
<li><a href="https://docs.polymarket.us/api-reference/rate-limits">Polymarket US: Rate limits &amp; latency stopgap</a></li>
<li><a href="https://docs.polymarket.us/api-reference/websocket/overview">Polymarket US: WebSocket overview</a></li>
<li><a href="https://code.claude.com/docs/en/settings">Claude Code: settings &amp; permissions</a></li>
</ul>
]]></content:encoded></item></channel></rss>