<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: BotCookery</title>
    <description>The latest articles on DEV Community by BotCookery (@botcookery).</description>
    <link>https://dev.to/botcookery</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4202705%2F79a0cfb7-0bbf-4620-94d6-28d0770848e7.png</url>
      <title>DEV Community: BotCookery</title>
      <link>https://dev.to/botcookery</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/botcookery"/>
    <language>en</language>
    <item>
      <title>Our Polymarket Telegram alert said "YES bid 0.64". It never said what the price was for.</title>
      <dc:creator>BotCookery</dc:creator>
      <pubDate>Fri, 13 Nov 2026 14:53:05 +0000</pubDate>
      <link>https://dev.to/botcookery/our-polymarket-telegram-alert-said-yes-bid-064-it-never-said-what-the-price-was-for-lj8</link>
      <guid>https://dev.to/botcookery/our-polymarket-telegram-alert-said-yes-bid-064-it-never-said-what-the-price-was-for-lj8</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botcookery.com/recipes/polymarket-telegram-alert-bot/" rel="noopener noreferrer"&gt;BotCookery&lt;/a&gt;, free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every output line below is from the Oct 25, 2026 rebuild, run on live Polymarket US public prices with Telegram mocked (&lt;code&gt;TELEGRAM_MOCK=1&lt;/code&gt;): no message was sent and the Telegram API was never called. The bot is alert-only, has no keys and no wallet, and never trades. Nothing here is financial advice.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A Polymarket Telegram alert bot is a small script that polls Polymarket US public prices (&lt;code&gt;GET https://gateway.polymarket.us/v1/markets/{slug}/bbo&lt;/code&gt;, no API key) and sends you a Telegram message through the Bot API's &lt;code&gt;sendMessage&lt;/code&gt; when a market crosses your level or moves fast. No wallet, no trading access, and the bot token stays in an env var.&lt;/p&gt;

&lt;p&gt;On Oct 25, 2026 we pasted the Oct 22 version of the BotCookery prompt into a clean folder and ran the bot it built. The watchlist had five slugs: the Chiefs–Seahawks game, the Miami high-temperature market for "83 to 84°F" on Oct 26, an eBasketball game with an almost empty book, an NFL game that settled last year, and one deliberate typo. The crossing logic and its tests were fine. The messages and the edge cases weren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one real alert we got
&lt;/h2&gt;

&lt;p&gt;At 4:08 PM ET the Miami book jumped from 0.59/0.60 to 0.64/0.67. The old build sent this (mocked):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16:08:20 tc-temp-miahigh-2026-10-26-gte83lt84f bid 0.64 ask 0.67 last 0.6 price 0.655
[mock telegram] Highest temperature in Miami on October 26? | YES bid 0.64 / ask 0.67 / last 0.6 | Rule: crossed above 0.62 | Alert only. Not advice.
[mock telegram] Highest temperature in Miami on October 26? | YES bid 0.64 / ask 0.67 / last 0.6 | Rule: moved +6.0 pts in 15 min | Alert only. Not advice.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two problems are in that message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: "YES" on a question every bucket shares
&lt;/h2&gt;

&lt;p&gt;"Highest temperature in Miami on October 26?" is the question for every temperature bucket in that event. "YES bid 0.64" doesn't tell you which bucket. On team markets "YES" is ambiguous too. Polymarket US prices are for the market's &lt;strong&gt;long side&lt;/strong&gt;, and the market-by-slug endpoint tells you what that is: &lt;code&gt;title&lt;/code&gt; (for example "83 to 84", sometimes missing) and &lt;code&gt;marketSides[]&lt;/code&gt;, where the side with &lt;code&gt;long=true&lt;/code&gt; is what the price is for. The rebuilt bot names it in every message: "83 to 84 (Yes)" or "Chiefs".&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: the alert was moved by quotes, not a trade
&lt;/h2&gt;

&lt;p&gt;The last trade stayed at 0.60 the whole time. The bot alerts on the mid of bid and ask, so a quote change alone fired both the level rule and the move rule. That's correct behavior, but you should be able to see it. New messages show bid, ask, mid and last trade, plus the local time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: a 0.505 "price" from an empty book
&lt;/h2&gt;

&lt;p&gt;The old build's first cycle at 4:07 PM ET:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16:07:33 aec-nfl-kc-sea-2026-10-25 bid 0.4125 ask 0.415 last 0.415 price 0.414
16:07:33 tc-temp-miahigh-2026-10-26-gte83lt84f bid 0.59 ask 0.6 last 0.6 price 0.595
16:07:33 aec-ebb-nyk-was-2026-10-25 bid 0.02 ask 0.99 last None price 0.505
aec-nfl-lac-ten-2025-11-02: no price
aec-nfl-kc-sea-2026-10-52: error 404 Client Error: Not Found for url: https://gateway.polymarket.us/v1/markets/aec-nfl-kc-sea-2026-10-52/bbo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The eBasketball book was 0.02 bid / 0.99 ask, so its "price" was 0.505. Any level near the middle would alert on noise. The fix is a &lt;code&gt;max_spread&lt;/code&gt; setting (default 0.05, per-slug override): if the spread is wider, log "spread too wide" and skip that poll.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: a typo that failed quietly, forever
&lt;/h2&gt;

&lt;p&gt;The misspelled slug logged a 404 on every poll and never stopped. The rebuilt bot checks every slug once at startup and stops with an error that names it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: a settled market that said "no price" forever
&lt;/h2&gt;

&lt;p&gt;The expired NFL market returns &lt;code&gt;settlementPx&lt;/code&gt; 1.0000 and an expired state. The old bot ignored both. The new one sends one "settled/closed at 1.00" message and drops the slug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smaller things
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No handling of Telegram's &lt;code&gt;retry_after&lt;/code&gt; on 429.&lt;/li&gt;
&lt;li&gt;The bot token sits in the Telegram URL, so an exception from the Telegram call could print it. The new prompt scrubs it from error text before logging.&lt;/li&gt;
&lt;li&gt;History grew forever. Now rows older than 2 days are dropped.&lt;/li&gt;
&lt;li&gt;"Ignore incoming messages" was unclear. The bot now never calls &lt;code&gt;getUpdates&lt;/code&gt; at all; a separate one-time helper gets your chat id.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  After: the rebuild from the new prompt
&lt;/h2&gt;

&lt;p&gt;With the typo still in the watchlist (4:10 PM ET):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;telegram: MOCK (logs/telegram_mock.jsonl)
[mock telegram] Los Angeles vs. Tennessee | Price is for: Chargers | aec-nfl-lac-ten-2025-11-02 is MARKET_STATE_EXPIRED, settled/closed at 1.00. Dropping it from the watchlist. | Alert only. Not advice.
ERROR: aec-nfl-kc-sea-2026-10-52 not found on Polymarket US. Check the slug in watchlist.yaml.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the typo fixed (4:11 PM ET, first cycle):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16:11:04 aec-nfl-kc-sea-2026-10-25 0.4125/0.415 mid 0.414
16:11:04 tc-temp-miahigh-2026-10-26-gte83lt84f 0.64/0.67 mid 0.655
16:11:04 aec-ebb-nyk-was-2026-10-25: spread too wide (0.02/0.99), skipped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prices didn't move during the live rerun, so we ran &lt;code&gt;--replay&lt;/code&gt; over the old build's saved history, which includes the real 4:08 PM jump. The same alert now reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sun 16:08 EDT
Highest temperature in Miami on October 26?
Price is for: 83 to 84 (Yes)
bid 0.640 / ask 0.670 / mid 0.655, last trade 0.600
Rule: crossed above 0.62
Alert only. Not advice.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The move alert is the same apart from "Rule: moved +6.0 pts in 15 min". This market had no &lt;code&gt;event_slug&lt;/code&gt; set, so there's no link. &lt;code&gt;--replay&lt;/code&gt; is worth keeping on its own: it runs the rules over saved samples with no network calls, so you can test your levels without waiting for the market.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built this before Oct 25?
&lt;/h2&gt;

&lt;p&gt;Paste this follow-up into the same project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fix these problems in this alert bot. Read alert_bot.py first and tell
me which ones apply before changing anything.
1. Name what the price is for. GET /v1/market/slug/{slug} has title
   (e.g. "83 to 84") and marketSides[]; the price is for the side with
   long=true. Put "Price is for: {title} ({long side})", or just the
   side if there's no title, under the question in every message.
2. Add max_spread (default 0.05, per slug override). If ask - bid is
   wider, log "spread too wide" and skip that poll.
3. At startup check every slug once. 404: stop with a clear error that
   names the slug. state not MARKET_STATE_OPEN: send one
   "settled/closed at {settlementPx}" message and drop the slug.
4. Add the local time and bid/ask/mid/last trade to each message.
5. De-duplicate per slug and rule type (above, below, move) for 30 min.
6. Telegram: on 429 wait the retry_after seconds it returns. Scrub the
   token from any exception text before logging it.
7. Remove any getUpdates call from the main loop. The bot reads no
   incoming messages.
8. Drop rows older than 2 days from data/history.jsonl.
9. Add --replay data/history.jsonl to run the rules over saved samples
   with no network calls.
Add tests for spread, dedup and the startup checks, and run them all.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Starting fresh? The full one-shot prompt is on the recipe page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why alert-only
&lt;/h2&gt;

&lt;p&gt;Third-party Telegram bots that trade for you usually need custody of funds or a private key, and that's a common way people get drained. An alert bot you run yourself, with no wallet and no keys, avoids that whole category. It only sends to your own chat id and never reads incoming messages, so nobody can command it. If the token leaks, revoke it in &lt;a class="mentioned-user" href="https://dev.to/botfather"&gt;@botfather&lt;/a&gt; (&lt;code&gt;/revoke&lt;/code&gt;) and make a new one. And poll gently: a 30 to 60 second poll is plenty for alerts, far below Polymarket US's 25 req/s retail limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;The page's verified line, word for word: &lt;em&gt;Verified working on live data in mock mode, Oct 25, 2026. Ran on Oct 25 with live Polymarket US public prices on five markets, including a settled one and a typo, plus a replay of a real price jump. Telegram was mocked: no message was sent and the Telegram API was never called, so real delivery and its 429 handling are not yet tested.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So this run shows the bot reads live prices, names what each price is for, skips junk books and handles typos and settled markets. It doesn't prove Telegram delivery. An alert is a notification, not a signal.&lt;/p&gt;

&lt;p&gt;The full recipe, with the complete prompt, the step-by-step build, the safety checklist and every doc we checked, is at &lt;strong&gt;&lt;a href="https://botcookery.com/recipes/polymarket-telegram-alert-bot/" rel="noopener noreferrer"&gt;botcookery.com/recipes/polymarket-telegram-alert-bot&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;API changes and new field notes: &lt;a class="mentioned-user" href="https://dev.to/botcookery"&gt;@botcookery&lt;/a&gt; on X (&lt;a href="https://x.com/botcookery" rel="noopener noreferrer"&gt;https://x.com/botcookery&lt;/a&gt;)&lt;/p&gt;

</description>
      <category>python</category>
      <category>telegram</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Our read-only Polymarket MCP server returned 480 rows for limit=20 and called a 0.07/0.99 book 99%</title>
      <dc:creator>BotCookery</dc:creator>
      <pubDate>Thu, 12 Nov 2026 14:55:20 +0000</pubDate>
      <link>https://dev.to/botcookery/our-read-only-polymarket-mcp-server-returned-480-rows-for-limit20-and-called-a-007099-book-99-3nbf</link>
      <guid>https://dev.to/botcookery/our-read-only-polymarket-mcp-server-returned-480-rows-for-limit20-and-called-a-007099-book-99-3nbf</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botcookery.com/recipes/polymarket-mcp-server-read-only/" rel="noopener noreferrer"&gt;BotCookery&lt;/a&gt;, free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every output line and count below is from the Oct 25, 2026 rebuild: each tool called through the MCP Python SDK's stdio client on live Polymarket US public data. No keys were involved, and the server has no trading tools. Registering it in an AI tool wasn't part of the test. Nothing here is financial advice. Market data only.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A read-only MCP server is a small local process that gives Claude Code, Cursor or any MCP client four tools (search markets, get market, best bid/offer, order book) backed by Polymarket US public endpoints. No API key, no order tools, so your assistant can look at markets but can't place anything.&lt;/p&gt;

&lt;p&gt;On Oct 25, 2026 we pasted the Oct 22 version of the BotCookery prompt into a clean folder, built every file it asks for, following it line by line, and called each tool through the SDK's stdio client against live Polymarket US data that afternoon. The safety parts held: the server started, refused to run with &lt;code&gt;POLYMARKET_SECRET_KEY&lt;/code&gt; set, and rejected bad slugs. The data parts didn't. &lt;code&gt;search_markets("bitcoin", limit=20)&lt;/code&gt; returned 480 rows and about 135,000 characters, and &lt;code&gt;get_market&lt;/code&gt; reported 99% on a market whose live book was bid 0.07, ask 0.99.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the prompt builds
&lt;/h2&gt;

&lt;p&gt;Four read-only tools on &lt;code&gt;https://gateway.polymarket.us&lt;/code&gt;, no auth:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;search_markets(query, limit)&lt;/code&gt; → &lt;code&gt;GET /v1/search&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_market(slug)&lt;/code&gt; → &lt;code&gt;GET /v1/market/slug/{slug}&lt;/code&gt;, plus best bid/offer&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_bbo(slug)&lt;/code&gt; → &lt;code&gt;GET /v1/markets/{slug}/bbo&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_order_book(slug, depth)&lt;/code&gt; → &lt;code&gt;GET /v1/markets/{slug}/book&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus mock-HTTP tests, a smoke script that calls every tool over stdio, and a README with the register command for Claude Code and Cursor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: &lt;code&gt;limit&lt;/code&gt; counted events, not markets
&lt;/h2&gt;

&lt;p&gt;Polymarket's search &lt;code&gt;limit&lt;/code&gt; counts events, and one event can hold dozens of markets. The literal build passed &lt;code&gt;limit&lt;/code&gt; straight through. The old build at 4:40 PM ET (long rows trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- search_markets {'query': 'bitcoin', 'limit': 20} chars=134823
[{"question":"Will Bitcoin be above ___ in 2026?", ...}, ... 480 rows]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's more than an assistant can usefully read, and 7 of those rows were resolved markets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: the price came from the wrong field
&lt;/h2&gt;

&lt;p&gt;The old &lt;code&gt;get_market&lt;/code&gt; read its price from &lt;code&gt;outcomePrices&lt;/code&gt;, whose meaning changes between endpoints. On a Fed funds market it said "last 0.99, 99%" while the live book was bid 0.07, ask 0.99.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: "implied %" from a book with nothing in the middle
&lt;/h2&gt;

&lt;p&gt;The mid of a 0.11 / 0.89 book is 50%. That number tells you nothing, but the tool reported it anyway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- get_bbo aec-nfl-tb-chi-2026-11-08
{"yes_bid":0.11,"yes_ask":0.89,"yes_last":null,"implied_pct":50.0, ...}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bug 4: "YES" wasn't always yes
&lt;/h2&gt;

&lt;p&gt;On game markets the long side is a team. "Who will win, Buccaneers vs Bears?" with a 0.11 bid doesn't say which team that price is for, and the old &lt;code&gt;get_market&lt;/code&gt; for the same game returned &lt;code&gt;"yes_last":0.89,"implied_pct":89.0&lt;/code&gt; with no side named.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 5: the register command couldn't import the SDK
&lt;/h2&gt;

&lt;p&gt;The setup step said &lt;code&gt;python3 /full/path/server.py&lt;/code&gt;. But &lt;code&gt;mcp&lt;/code&gt; and &lt;code&gt;httpx&lt;/code&gt; live in the project's venv, so system Python fails with &lt;code&gt;ModuleNotFoundError&lt;/code&gt;. The SDK version wasn't pinned either, and many AI tools still write the v1 &lt;code&gt;FastMCP&lt;/code&gt; import, which v2 renamed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 6: 429s far below the documented limit
&lt;/h2&gt;

&lt;p&gt;We stayed far below the documented 25 requests a second, and Polymarket still answered 429 twice during testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Search returns market rows, open only.&lt;/strong&gt; Flatten events to one row per market (question, event, slug, &lt;code&gt;long_side&lt;/code&gt;, status, end date), drop closed or inactive markets, then cut to &lt;code&gt;limit&lt;/code&gt; rows. No prices in search; it says "use get_bbo for prices".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prices only from &lt;code&gt;/bbo&lt;/code&gt; and &lt;code&gt;/book&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;bestBid&lt;/code&gt;, &lt;code&gt;bestAsk&lt;/code&gt;, &lt;code&gt;lastTradePx&lt;/code&gt;, any of which can be null). Never &lt;code&gt;outcomePrices&lt;/code&gt; or &lt;code&gt;currentPx&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implied % only when the spread is 0.10 or less.&lt;/strong&gt; Otherwise it's null with a reason, and a missing bid or ask is handled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;long_side&lt;/code&gt; named in every response&lt;/strong&gt;, from the &lt;code&gt;marketSides&lt;/code&gt; entry with &lt;code&gt;long=true&lt;/code&gt;, with a note that every price is for that side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every response under 4,000 characters&lt;/strong&gt;, dropping whole rows and keeping valid JSON with &lt;code&gt;"truncated": true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slug regex &lt;code&gt;^[a-z0-9][a-z0-9-]*$&lt;/code&gt;&lt;/strong&gt;, and on 429 a 1 s wait, exponential backoff, then a clear &lt;code&gt;ToolError&lt;/code&gt; instead of hanging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mcp[cli]&amp;gt;=2,&amp;lt;3&lt;/code&gt; pinned in a venv&lt;/strong&gt;, and the register command uses full paths to the venv's Python and &lt;code&gt;server.py&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  After: a fresh rebuild
&lt;/h2&gt;

&lt;p&gt;The rebuilt server at about 4:45 PM ET, same calls (trimmed). 7 tests pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- search_markets bitcoin, limit 20: 17 shown of 471 open matches,
    truncated, 3898 chars
--- get_market aec-nfl-tb-chi-2026-11-08
{"long_side":"Buccaneers","yes_bid":0.11,"yes_ask":0.89,"yes_last":null,
 "spread":0.78,"implied_pct":null,"implied_reason":"spread 0.78 too wide",
 "note":"Prices are for the long side: Buccaneers.", ...}
--- get_bbo aachc-nfl-2027-01-10-lastundefeated-kc
{"yes_bid":0.27,"yes_ask":0.33,"yes_last":0.35,"spread":0.06,
 "implied_pct":30.0, ...}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Search asked for 20 rows and stopped at 17 to stay under 4,000 characters. The wide book says why it has no implied %, and the tight one gets one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Register it (full paths)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claude mcp add --transport stdio polymarket-readonly -- /full/path/.venv/bin/python /full/path/server.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or in Cursor's &lt;code&gt;.cursor/mcp.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"polymarket-readonly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"/full/path/.venv/bin/python"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"/full/path/server.py"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Built this before Oct 25?
&lt;/h2&gt;

&lt;p&gt;Paste this follow-up into the same project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Update my read-only Polymarket US MCP server. Keep it read-only and
keep the startup check that refuses POLYMARKET_SECRET_KEY.
1. search_markets: the API's limit counts EVENTS, not markets. Flatten
   to one row per market (question, event, slug, long_side, status,
   endDate), drop markets that aren't active or are closed, then cut
   to `limit` rows. No prices in search; say "use get_bbo for prices".
2. Prices: take YES prices ONLY from /bbo or /book (bestBid, bestAsk,
   lastTradePx; any can be null). Stop using outcomePrices and
   currentPx. implied_pct is the mid only when both sides exist and
   the spread is &amp;lt;= 0.10; otherwise null with a reason.
3. long_side: the description of the marketSides entry with long=true
   ("Yes", or a team name on game markets). Show it in search rows and
   get_market, and say every price is for that side.
4. Keep every response under 4,000 characters by dropping whole rows
   (valid JSON, "truncated": true). Keep the closing line.
5. Slugs must match ^[a-z0-9][a-z0-9-]*$. On 429 wait 1s, then back
   off exponentially; after a few tries raise a ToolError.
6. Pin "mcp[cli]&amp;gt;=2,&amp;lt;3" in a venv. Register with FULL paths to the
   venv's python and server.py, not plain python3.
7. Add mock tests for the row limit, closed markets, a null ask, a
   wide spread and long_side. Run all tests and the stdio smoke script.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Starting fresh? The full one-shot prompt is on the recipe page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why read-only
&lt;/h2&gt;

&lt;p&gt;An MCP tool can be called by the AI on its own initiative, and agents make mistakes or get steered by text they read, including market text the tools return. Leaving order tools out removes the worst failure. If you later want trading, build it as a separate program with a paper/live gate, and don't hand an agent an order tool. Also: an MCP server runs code on your machine with your permissions, so only install ones you've read or trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;The page's verified line, word for word: &lt;em&gt;Verified working on live data in read-only mode, Oct 25, 2026. Every tool called through the SDK's stdio client on live public data. Registering it in an AI tool wasn't tested.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So this run proves the tools return sane, bounded data over stdio on live Polymarket US markets. It doesn't prove how any particular assistant uses them once registered. The output is market data, not a signal.&lt;/p&gt;

&lt;p&gt;The full recipe, with the complete prompt, the step-by-step build, the safety checklist and every doc we checked, is at &lt;strong&gt;&lt;a href="https://botcookery.com/recipes/polymarket-mcp-server-read-only/" rel="noopener noreferrer"&gt;botcookery.com/recipes/polymarket-mcp-server-read-only&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;API changes and new field notes: &lt;a class="mentioned-user" href="https://dev.to/botcookery"&gt;@botcookery&lt;/a&gt; on X (&lt;a href="https://x.com/botcookery" rel="noopener noreferrer"&gt;https://x.com/botcookery&lt;/a&gt;)&lt;/p&gt;

</description>
      <category>python</category>
      <category>mcp</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Our ChatGPT/Codex Kalshi starter would have bought the same contract 38 times in under 13 minutes</title>
      <dc:creator>BotCookery</dc:creator>
      <pubDate>Wed, 11 Nov 2026 14:52:39 +0000</pubDate>
      <link>https://dev.to/botcookery/our-chatgptcodex-kalshi-starter-would-have-bought-the-same-contract-38-times-in-under-13-minutes-9c2</link>
      <guid>https://dev.to/botcookery/our-chatgptcodex-kalshi-starter-would-have-bought-the-same-contract-38-times-in-under-13-minutes-9c2</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botcookery.com/recipes/chatgpt-trading-bot-codex/" rel="noopener noreferrer"&gt;BotCookery&lt;/a&gt;, free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every log line and count below is from the Oct 25, 2026 rebuild: a dry run with no keys on Kalshi's live public demo market data. Nothing was signed with a real key and no orders were sent. Nothing here is financial advice. Demo and dry run only.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Can ChatGPT build me a trading bot?" Yes, it can write the code. It can't make the bot profitable, and it often remembers outdated API endpoints. The BotCookery recipe is a prompt for ChatGPT (chat) or Codex (agent) that builds a small Kalshi bot on the &lt;strong&gt;demo&lt;/strong&gt; exchange, with the rules in &lt;code&gt;AGENTS.md&lt;/code&gt; and the limits in code.&lt;/p&gt;

&lt;p&gt;On Oct 25, 2026 we pasted the Oct 22 version of that prompt into a clean folder, built every file it asks for, following it line by line, and ran the bot without keys against live Kalshi demo data that afternoon. The V2 order body, the dry run and the kill switch all worked. The trading rule did not: while the ask stayed low, it proposed a new 1-contract bid on every loop. On one ticker that was 38 orders in under 13 minutes, then 38 more after a restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the prompt builds
&lt;/h2&gt;

&lt;p&gt;The bot is built one file at a time, waiting for your "next" between files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;kalshi_client.py&lt;/code&gt;: signed requests (RSA-PSS with SHA-256 or Ed25519), base URL fixed to &lt;code&gt;https://external-api.demo.kalshi.co/trade-api/v2&lt;/code&gt;. Key id and key file path come from env vars. Without them the bot dry-runs: it reads public data, logs what it would send, and sends nothing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;risk.py&lt;/code&gt;: &lt;code&gt;limits.json&lt;/code&gt; (1 contract per order, $5 open exposure, $2 daily loss, 3 orders a minute) plus a &lt;code&gt;KILL&lt;/code&gt; file or &lt;code&gt;BOT_KILL=1&lt;/code&gt;. If any check errors, the order is blocked.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bot.py&lt;/code&gt;: watches one ticker, reads &lt;code&gt;yes_ask_dollars&lt;/code&gt; and &lt;code&gt;yes_ask_size_fp&lt;/code&gt;, and places through &lt;code&gt;POST /portfolio/events/orders&lt;/code&gt; (Create Order V2) with side &lt;code&gt;"bid"&lt;/code&gt;, count &lt;code&gt;"1"&lt;/code&gt; and a dollar-string price like &lt;code&gt;"0.4500"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cancel_all.py&lt;/code&gt;: &lt;code&gt;DELETE /portfolio/events/orders&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;find_ticker.py&lt;/code&gt;, a README and a &lt;code&gt;.gitignore&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the model writes the legacy &lt;code&gt;/portfolio/orders&lt;/code&gt; for order creation, it's working from old memory, so re-paste the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: "1 contract per order" isn't a position limit
&lt;/h2&gt;

&lt;p&gt;The old build at 4:32 PM ET, no keys, ticker &lt;code&gt;KXNFLTD-26OCT25KCSEA-KCAARMSTRONG85-1&lt;/code&gt; (Andrew Armstrong to score a touchdown), ask $0.13, max price $0.15 (timestamps and order IDs trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"decision": "dry_run_would_send", "body": {"ticker": "KXNFLTD-26OCT25KCSEA-KCAARMSTRONG85-1", "side": "bid", "count": "1", "price": "0.1300", ...}}
{"decision": "dry_run_would_send", "body": {... "price": "0.1300", ...}}
{"decision": "dry_run_would_send", "body": {... "price": "0.1300", ...}}
{"decision": "blocked", "reason": "max_orders_per_minute", "ask": 0.13}
{"decision": "blocked", "reason": "max_orders_per_minute", "ask": 0.13}
{"decision": "blocked", "reason": "max_orders_per_minute", "ask": 0.13}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the 3-a-minute limit slowed it, and only the $5 exposure cap stopped it. The same risk code on a fake clock: one hour at ask 0.13 allowed 38 buy orders and $4.94 of exposure, and after a restart, 38 more.&lt;/p&gt;

&lt;p&gt;Every individual order obeyed "1 contract per order". The bot just kept making them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: "$5 exposure" and "$2 daily loss" weren't defined
&lt;/h2&gt;

&lt;p&gt;A literal build counted orders sent, in memory, and left daily loss at 0. So a restart wiped exposure, and the daily loss limit could never trip. A limit that can't trip looks exactly like a limit that's working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: "keep the key outside your project" doesn't hide it from Codex
&lt;/h2&gt;

&lt;p&gt;We tested Codex CLI 0.164.0's sandbox on its own (no model, a throwaway file, not a key). In its normal edit mode, both &lt;code&gt;cat&lt;/code&gt; and a Python script read &lt;code&gt;~/.config/kalshi/demo.pem&lt;/code&gt;. A Codex permission profile that denies that folder blocked both, and project writes still worked (paths shortened):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- default
THROWAWAY-NOT-A-KEY
THROWAWAY-NOT-A-KEY
project write ok
--- with "~/.config/kalshi" = "deny"
cat: ~/.config/kalshi/demo.pem: Permission denied
PermissionError: [Errno 13] Permission denied: '~/.config/kalshi/demo.pem'
project write ok
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bugs 4 to 6: docs, ticker search, the small stuff
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The pasted docs didn't cover the code.&lt;/strong&gt; The prompt said to use only endpoints from the pasted pages, but &lt;code&gt;GET /markets/{ticker}&lt;/code&gt; and the cancel-all &lt;code&gt;DELETE&lt;/code&gt; weren't on the two pages it listed. A careful model should stop and say so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ticker search read one page.&lt;/strong&gt; That returns 1,000 markets plus a cursor, and only 19 had an ask. Following the cursor gave 217 pages and 7,348 markets with an ask, and Kalshi rate-limited us partway (HTTP 429).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small things.&lt;/strong&gt; Only &lt;code&gt;risk.py&lt;/code&gt; had tests, so a wrong signature would show up as 401s on your first keyed run. The &lt;code&gt;.gitignore&lt;/code&gt; was thin.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One position per ticker.&lt;/strong&gt; Before proposing, the bot checks for a resting order or a position on the ticker (in dry run, a paper order in &lt;code&gt;state/paper.json&lt;/code&gt;) and logs "holding". It skips unless the market status is &lt;code&gt;"active"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exposure is computed, not counted.&lt;/strong&gt; Before every order: resting orders (&lt;code&gt;yes_price_dollars * remaining_count_fp&lt;/code&gt;) plus positions' &lt;code&gt;market_exposure_dollars&lt;/code&gt;, from your demo account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daily loss from the balance.&lt;/strong&gt; Today's first balance plus portfolio value from &lt;code&gt;GET /portfolio/balance&lt;/code&gt; is saved in &lt;code&gt;state/day_start.json&lt;/code&gt;. In dry run it logs "daily loss not tracked" instead of pretending it's 0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order timestamps saved to disk&lt;/strong&gt;, so a restart doesn't reset the per-minute limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 3 sets up a Codex permission profile&lt;/strong&gt; that denies &lt;code&gt;~/.config/kalshi&lt;/code&gt; (shown below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The prompt lists every docs page it needs:&lt;/strong&gt; Quick Start authenticated requests, Create Order (V2), Get Market, Get Markets, Get Orders, Get Positions, Get Balance, Cancel All Orders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;find_ticker.py&lt;/code&gt; follows the cursor&lt;/strong&gt; through &lt;code&gt;GET /markets?status=open&amp;amp;mve_filter=exclude&amp;amp;limit=1000&lt;/code&gt; and retries on 429 (1, 2, 4, 8, 16 s).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signing tests&lt;/strong&gt; for RSA and Ed25519 against the public key, with a fake HTTP session. &lt;code&gt;.gitignore&lt;/code&gt; now covers &lt;code&gt;.env.*&lt;/code&gt;, &lt;code&gt;*.key&lt;/code&gt; and &lt;code&gt;state/&lt;/code&gt;. Without keys, &lt;code&gt;cancel_all.py&lt;/code&gt; exits 1 and says nothing was sent.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Codex step
&lt;/h2&gt;

&lt;p&gt;Edit &lt;code&gt;~/.codex/config.toml&lt;/code&gt; yourself, not through Codex, and restart it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="na"&gt;default_permissions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"kalshi-bot"&lt;/span&gt;
&lt;span class="k"&gt;[permissions.kalshi-bot]&lt;/span&gt;
&lt;span class="na"&gt;extends&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;":workspace"&lt;/span&gt;
&lt;span class="k"&gt;[permissions.kalshi-bot.filesystem]&lt;/span&gt;
&lt;span class="s2"&gt;"~/.config/kalshi"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"deny"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Permission profiles are a beta feature and don't combine with &lt;code&gt;sandbox_mode&lt;/code&gt; or &lt;code&gt;--sandbox&lt;/code&gt;, so leave those out. Then ask Codex to &lt;code&gt;cat ~/.config/kalshi/demo.pem&lt;/code&gt;; it should get "Permission denied". In plain ChatGPT chat, skip this step, since chat can't read your files.&lt;/p&gt;

&lt;h2&gt;
  
  
  After: a fresh rebuild
&lt;/h2&gt;

&lt;p&gt;A fresh rebuild of the new prompt (10 tests pass), 4:33 PM ET, same ticker, then a restart, then the kill-switch drill (timestamps and order ID trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"decision": "dry_run", "body": {... "count": "1", "price": "0.1300", ...}, "exposure": 0, "daily_loss": "not tracked in dry run"}
{"decision": "holding", "ask": 0.13}
{"decision": "holding", "ask": 0.13}
--- restart
{"decision": "holding", "ask": 0.13}
--- KILL drill
{"decision": "blocked", "reason": "kill switch on", "ask": 0.13}
--- cancel_all
No Kalshi credentials set (KALSHI_API_KEY_ID / KALSHI_PRIVATE_KEY_PATH). Nothing was sent.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One paper order, then "holding", and still "holding" after a restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built this before Oct 25?
&lt;/h2&gt;

&lt;p&gt;Do the Codex permission step above, then paste this follow-up into the same project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Update this Kalshi demo bot. Use only the Kalshi docs pages I paste
below (Get Market, Get Markets, Get Orders, Get Positions, Get Balance,
Cancel All Orders). Never run the bot with my keys; I run it myself.
1. One position only: before proposing, skip and log "holding" if this
   ticker has a resting order (GET /portfolio/orders?ticker=...&amp;amp;
   status=resting) or a position (GET /portfolio/positions?ticker=...,
   position_fp != 0). In dry-run, keep paper orders in state/paper.json
   and apply the same rule. Skip unless the market status is "active".
2. Open exposure: compute it from the exchange before each order
   (resting yes_price_dollars * remaining_count_fp plus positions'
   market_exposure_dollars), not from a counter of orders sent.
3. Daily loss: save today's first balance + portfolio_value from
   GET /portfolio/balance (cents) in state/day_start.json and block when
   the current value is max_daily_loss_usd below it. No placeholder 0.
4. Save order timestamps in state/ so a restart doesn't reset the
   per-minute limit. If any risk check errors, block the order.
5. cancel_all.py with no credentials: exit 1, "Nothing was sent."
6. Add find_ticker.py: follow the cursor through
   GET /markets?status=open&amp;amp;mve_filter=exclude&amp;amp;limit=1000, wait and
   retry on HTTP 429, print markets with 0 &amp;lt; yes_ask_dollars &amp;lt; 1.
7. Add .env.*, *.key and state/ to .gitignore.
8. Tests with a fake HTTP session for each of these, plus RSA and
   Ed25519 signature checks against the public key.
Show me the diff before running anything.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Starting fresh? The full one-shot prompt is on the recipe page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chat or Codex?
&lt;/h2&gt;

&lt;p&gt;In chat you copy code by hand: slower, but you see every line. Codex edits files and runs commands: faster, so the rules matter more. Either way, never paste keys, key files or account screenshots into ChatGPT, and run the bot with real keys in your own terminal, not through Codex.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;The page's verified line, word for word: &lt;em&gt;Verified working on live data in paper mode, Oct 25, 2026. Dry run on live Kalshi demo data with no keys. Signed demo orders need your own demo keys.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So this run proves plumbing on public demo data: the V2 order body, the one-position rule, the kill switch and the paper ledger. The exposure, daily-loss and holding checks against real demo orders are covered by tests with a fake HTTP session, not by a signed run. You'll need your own demo keys for that. Kalshi notes demo prices may not reflect real markets, so judge your code there, not your strategy. There's no evidence that asking ChatGPT for trades produces reliable profits. Use it to build tools.&lt;/p&gt;

&lt;p&gt;The full recipe, with the complete prompt, the step-by-step build, the safety checklist and every doc we checked, is at &lt;strong&gt;&lt;a href="https://botcookery.com/recipes/chatgpt-trading-bot-codex/" rel="noopener noreferrer"&gt;botcookery.com/recipes/chatgpt-trading-bot-codex&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;API changes and new field notes: &lt;a class="mentioned-user" href="https://dev.to/botcookery"&gt;@botcookery&lt;/a&gt; on X (&lt;a href="https://x.com/botcookery" rel="noopener noreferrer"&gt;https://x.com/botcookery&lt;/a&gt;)&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>openai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Our weather bot paper-bought "below 81 " at 1¢ after the day's high had already hit 80.96</title>
      <dc:creator>BotCookery</dc:creator>
      <pubDate>Tue, 10 Nov 2026 15:02:30 +0000</pubDate>
      <link>https://dev.to/botcookery/our-weather-bot-paper-bought-below-81deg-at-1c-after-the-days-high-had-already-hit-8096deg-26lg</link>
      <guid>https://dev.to/botcookery/our-weather-bot-paper-bought-below-81deg-at-1c-after-the-days-high-had-already-hit-8096deg-26lg</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botcookery.com/recipes/prediction-market-weather-bot/" rel="noopener noreferrer"&gt;BotCookery&lt;/a&gt;, free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every number and log line below comes from a live paper-mode run on Miami temperature markets (Kalshi &lt;code&gt;KXHIGHMIA&lt;/code&gt; and Polymarket US) on the afternoon of Oct 25, 2026. No orders were placed anywhere, demo included. Nothing here is financial advice.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Daily-high temperature markets look made for a bot. The National Weather Service publishes a free forecast API, both Kalshi and Polymarket US list a new set of temperature buckets every day, and the answer arrives the next morning. So it seems like you could compare the forecast with the prices and trade the gap.&lt;/p&gt;

&lt;p&gt;We have a recipe that builds this in paper mode: it pulls the free NWS API and Kalshi's public weather index, parses each day's buckets on both venues, and logs what a simple rule would do, then scores it against the settlement source. On Oct 25 we pasted the Oct 22 version of the prompt into a clean folder, built it for Miami, and ran it live through the afternoon. It tracked 24 markets across Oct 25 and Oct 26.&lt;/p&gt;

&lt;p&gt;The parser was fine. Most of the rest needed work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The worst bug: trading a day that was mostly decided
&lt;/h2&gt;

&lt;p&gt;At 3:15 PM ET the bot paper-bought 500 YES contracts on "below 81° today" at $0.01. It used the NWS forecast for "This Afternoon" (83°) as if the high were still to come.&lt;/p&gt;

&lt;p&gt;By then Kalshi's weather index had already reached 80.96° for the day, and the 81-82° bucket was bid at $0.99. The market knew the answer. The bot was still trading on the morning's forecast.&lt;/p&gt;

&lt;p&gt;Here's the old build at 3:15 PM ET (host overridden by hand, more on that below):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kalshi KXHIGHMIA-26OCT25-T81              (None, 80)   ok           fc=83 obs=80.6 bid=0.0 ask=0.01 p=0.2023 BUY_YES ok
kalshi KXHIGHMIA-26OCT25-B81.5            (81, 82)     ok           fc=83 obs=80.6 bid=0.99 ask=1.0 p=0.2315 BUY_NO daily cap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is one config line and a rule: &lt;code&gt;trade_same_day = false&lt;/code&gt;. Any market whose date is today or earlier, in the station's local standard time, is logged as &lt;code&gt;SKIP same day&lt;/code&gt;. Once the day has started, the high is partly in, and a forecast can't compete with people watching the station.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five more things that broke
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It crashed on the first call.&lt;/strong&gt; The prompt's Kalshi host, &lt;code&gt;external-api.kalshi.com&lt;/code&gt;, answered 403 from our server, and the whole cycle died with a traceback. Kalshi also documents &lt;code&gt;api.elections.kalshi.com&lt;/code&gt;, and that one worked. The bot now falls back once with a one-line note, and a single failing source (Kalshi, Polymarket US or NWS) is logged and skipped instead of killing the cycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Latest observed value" was the wrong number.&lt;/strong&gt; The bot logged the last index reading, 80.24, instead of the day's high so far, 80.96. For a daily-high market, the running max is what matters. It now logs the running max since local standard midnight from both the Kalshi index and the station's own observations (the station's 5-minute values are whole °C, which the log now says).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It re-bought the same markets every cycle.&lt;/strong&gt; Nothing checked the ledger. On the second cycle 17 signals fired, including markets it already held, and only the daily cap stopped them. Now it's one paper position per market, and signals are ranked by edge before the cap is spent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kalshi paper fills had no size limit, and nothing paid fees.&lt;/strong&gt; On the 1-2¢ buckets the model liked, Kalshi's taker fee is about 7% of the stake ($0.35 on a $5 buy). Fills now walk the real order book (Kalshi's book is bids only, so YES asks are 1 minus the NO bids), and the ledger charges each venue's published taker fee: Kalshi &lt;code&gt;0.07 × C × P × (1 − P)&lt;/code&gt; rounded up to the cent, Polymarket US &lt;code&gt;0.0695 × C × p × (1 − p)&lt;/code&gt;, as we read both fee pages on Oct 25.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The score had nothing to compare against.&lt;/strong&gt; &lt;code&gt;settle.py&lt;/code&gt; printed a Brier score for our estimates but not for the market's own prices, so there was no way to tell whether the model beat the market. It now prints both on the same decisions, plus paper PnL after fees, and says so plainly when ours isn't lower.&lt;/p&gt;

&lt;h2&gt;
  
  
  What didn't change, on purpose
&lt;/h2&gt;

&lt;p&gt;The placeholder model is a normal curve around the NWS forecast, and it's too wide. On Oct 26 it gave 12% to "87° or above", where both venues priced it at about 2%. Most of its "edge" is the curve's fat tails, not information.&lt;/p&gt;

&lt;p&gt;We left that alone because the recipe is about plumbing and honest scoring, not a forecast model. The market-vs-model Brier comparison is there to show you exactly this.&lt;/p&gt;

&lt;h2&gt;
  
  
  After the fix
&lt;/h2&gt;

&lt;p&gt;From a fresh rebuild of the new prompt (11 tests pass), at 3:21 PM ET the same afternoon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;note: external-api.kalshi.com returned 403; using api.elections.kalshi.com
2026-10-25 15:21 EDT  climate day 2026-10-25  observed max so far: Kalshi index 80.96, KMIA 80.6 (ASOS 5-min values are whole °C)
kalshi KXHIGHMIA-26OCT26-T86  (87, None)  The Weat bid=0.01 ask=0.02 fc=83 p=0.1217 BUY_YES ok FILL 226@0.0221 fee 0.35
kalshi XHIGHMIA-26OCT26-B83.5 (83, 84)    The Weat bid=0.62 ask=0.63 fc=83 p=0.2576 BUY_NO ok FILL 13@0.38 fee 0.22
kalshi KXHIGHMIA-26OCT25-T81  (None, 80)  The Weat bid=0.0 ask=0.01 fc=83 p=None SKIP same day
pmus   -2026-10-26-gte81lt82f (81, 82)    NWS CLI  bid=0.17 ask=0.18 fc=83 p=0.2315 BUY_YES daily cap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same-day market is skipped, fills carry a size and a fee, and the Polymarket US bucket shows its settlement source (NWS CLI). Three minutes later, the second cycle said "already held" for every repeat signal and bought nothing again. With Kalshi unreachable, the 12 Polymarket US markets still ran and the bot exited 1. With a &lt;code&gt;KILL&lt;/code&gt; file, all 8 signals were blocked. &lt;code&gt;settle.py&lt;/code&gt; read all four Oct 24 test entries as settled on both venues and left the open Oct 26 one alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things we confirmed live
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The buckets move every day.&lt;/strong&gt; Oct 24 ran 80-81, 82-83, 84-85 and so on, while Oct 26 ran 79-80, 81-82, 83-84. A bot that hardcodes bucket edges breaks within a day, so it has to parse every market, every day. Kalshi titles look like &lt;code&gt;Will the maximum temperature be &amp;gt;67° on Oct 19, 2026?&lt;/code&gt;, often with no &lt;code&gt;F&lt;/code&gt; after the degree sign, and &lt;code&gt;&amp;gt;86°&lt;/code&gt; means 87° or above. Polymarket US slugs like &lt;code&gt;gte79lt80f&lt;/code&gt; need the market description to be read correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Different sources, same answer, that day.&lt;/strong&gt; When we checked on Oct 22, Kalshi's Miami daily-high markets named The Weather Company (CLIMIA) in &lt;code&gt;rules_primary&lt;/code&gt;, while Polymarket US's named the NWS climate report for KMIA. On Oct 24 both settled at 84°: Kalshi &lt;code&gt;expiration_value&lt;/code&gt; 84.00, Polymarket US "between 84F and 85F" = Yes, and the NWS climate report max was 84° at 2:02 PM. That won't always hold, so read &lt;code&gt;rules_primary&lt;/code&gt; on every Kalshi market and the description on every Polymarket US market, and store the source with each trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to try it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Pick one city both venues list and note each venue's settlement source for it.&lt;/li&gt;
&lt;li&gt;Paste the prompt from the recipe page into a fresh folder. Set your NWS &lt;code&gt;User-Agent&lt;/code&gt; (app name plus email); api.weather.gov asks for one.&lt;/li&gt;
&lt;li&gt;Print each market's text next to the parsed range and check them by eye. Anything the parser isn't sure about is "needs review" and never traded, even on paper.&lt;/li&gt;
&lt;li&gt;Leave &lt;code&gt;trade_same_day = false&lt;/code&gt;. Run a few times a day for 2-4 weeks, and run &lt;code&gt;settle.py&lt;/code&gt; each morning after settlement.&lt;/li&gt;
&lt;li&gt;Compare your Brier score with the market's. If yours isn't lower, that's your answer, whatever a few weeks of paper PnL say.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Built it before Oct 25? The recipe page has a follow-up prompt that applies all of these fixes to an existing project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;The recipe page's verified line, word for word: &lt;em&gt;Verified working on live data in paper mode, Oct 25, 2026. Ran live in paper mode on Miami (Kalshi KXHIGHMIA and Polymarket US) for a few cycles on the afternoon of Oct 25. settle.py was checked against the Oct 24 markets that had already settled. Our own paper trades had not settled when we published, so there is no PnL or Brier result yet. No orders were placed anywhere, demo included.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This was one city on one afternoon. It shows the plumbing works: host fallback, same-day skip, running max, one position per market, size-limited fills, fees and settlement reads. It doesn't show that the model, or any weather model, has an edge. Fee formulas are as we read them on Oct 25; check both venues' fee pages before trusting any number here.&lt;/p&gt;

&lt;p&gt;The full recipe, with the complete prompt, the follow-up, the safety checklist and every doc we checked (Polymarket US weather FAQ, Kalshi weather index and market rules, the NWS API), is at &lt;strong&gt;&lt;a href="https://botcookery.com/recipes/prediction-market-weather-bot/" rel="noopener noreferrer"&gt;botcookery.com/recipes/prediction-market-weather-bot&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;API changes and new field notes: &lt;a class="mentioned-user" href="https://dev.to/botcookery"&gt;@botcookery&lt;/a&gt; on X (&lt;a href="https://x.com/botcookery" rel="noopener noreferrer"&gt;https://x.com/botcookery&lt;/a&gt;)&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>tutorial</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Our Claude Kalshi bot counted a closed market's paper order forever</title>
      <dc:creator>BotCookery</dc:creator>
      <pubDate>Mon, 09 Nov 2026 19:49:39 +0000</pubDate>
      <link>https://dev.to/botcookery/our-claude-kalshi-bot-counted-a-closed-markets-paper-order-forever-2377</link>
      <guid>https://dev.to/botcookery/our-claude-kalshi-bot-counted-a-closed-markets-paper-order-forever-2377</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botcookery.com/recipes/claude-trading-bot/" rel="noopener noreferrer"&gt;BotCookery&lt;/a&gt;, free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every log line and count below is from the Nov 9, 2026 rebuild: a dry run with no keys on Kalshi's live public demo data, plus fault drills through a local proxy. No key was used, no signed request was sent, and no order was placed. Nothing here is financial advice. Demo and dry run only.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The BotCookery Claude trading-bot recipe is a prompt that sets up Claude Code safely (a &lt;code&gt;CLAUDE.md&lt;/code&gt; rules file, &lt;code&gt;permissions.deny&lt;/code&gt; rules for &lt;code&gt;.env&lt;/code&gt; and key files, a sandbox &lt;code&gt;denyRead&lt;/code&gt; on the key folder) and then scaffolds a small Kalshi &lt;strong&gt;demo&lt;/strong&gt; bot with its limits in code. On Oct 25 we rebuilt it once and fixed the big problems. On the morning of Nov 9, 2026 we rebuilt it again from the published prompt in an empty folder, writing every file it asks for and nothing more, and ran it without keys on live Kalshi demo data from 9:02 to 9:14 AM ET.&lt;/p&gt;

&lt;p&gt;The Oct 25 fixes held. What broke was everything around the happy path. The finding we like least: in a dry run, a paper order on a market that later closed stayed in &lt;code&gt;state/paper.json&lt;/code&gt; forever. The bot said "holding" on every loop, and that dead order kept counting toward the exposure cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built
&lt;/h2&gt;

&lt;p&gt;The prompt asks for these files: &lt;code&gt;broker.py&lt;/code&gt;, &lt;code&gt;risk.py&lt;/code&gt;, &lt;code&gt;bot.py&lt;/code&gt;, &lt;code&gt;cancel_all.py&lt;/code&gt;, &lt;code&gt;find_ticker.py&lt;/code&gt;, &lt;code&gt;config.toml&lt;/code&gt; and &lt;code&gt;tests/&lt;/code&gt;. Python 3.11+, &lt;code&gt;requests&lt;/code&gt;, &lt;code&gt;cryptography&lt;/code&gt; and &lt;code&gt;pytest&lt;/code&gt; only.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;broker.py&lt;/code&gt; hardcodes the demo base URL &lt;code&gt;https://external-api.demo.kalshi.co/trade-api/v2&lt;/code&gt;. Production is a separate, deliberate change.&lt;/li&gt;
&lt;li&gt;With no credentials the bot dry-runs: it reads public market data, logs what it would send, sends nothing, and keeps dry-run "orders" in &lt;code&gt;state/paper.json&lt;/code&gt; so the same rules survive a restart.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bot.py&lt;/code&gt; watches one ticker and a &lt;code&gt;max_price&lt;/code&gt; from &lt;code&gt;config.toml&lt;/code&gt;. The rule is simple: the best YES ask is at or below &lt;code&gt;max_price&lt;/code&gt; and size is at least 1. One position only: if there's a resting order, a position, or (in dry run) a paper order on that ticker, it logs "holding" and skips.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;risk.py&lt;/code&gt; checks before every order and fails closed: the &lt;code&gt;KILL&lt;/code&gt; file or &lt;code&gt;BOT_KILL=1&lt;/code&gt;, max contracts, open exposure computed from the exchange each time, daily loss from &lt;code&gt;GET /portfolio/balance&lt;/code&gt;, and orders per minute from timestamps saved in &lt;code&gt;state/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Orders go to &lt;code&gt;POST /portfolio/events/orders&lt;/code&gt; with &lt;code&gt;side&lt;/code&gt; &lt;code&gt;"bid"&lt;/code&gt; (buy YES), &lt;code&gt;count&lt;/code&gt; &lt;code&gt;"1.00"&lt;/code&gt;, a price like &lt;code&gt;"0.3500"&lt;/code&gt;, a &lt;code&gt;client_order_id&lt;/code&gt;, &lt;code&gt;good_till_canceled&lt;/code&gt; and &lt;code&gt;self_trade_prevention_type&lt;/code&gt; &lt;code&gt;taker_at_cross&lt;/code&gt;. &lt;code&gt;cancel_all.py&lt;/code&gt; is &lt;code&gt;DELETE /portfolio/events/orders&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The build started at 13 tests. We also checked the Claude Code settings and sandbox keys and the Kalshi V2 order body against the current docs. All 13 doc links on the page returned 200, including Kalshi's changelog, which we added.&lt;/p&gt;

&lt;h2&gt;
  
  
  What held from Oct 25
&lt;/h2&gt;

&lt;p&gt;In 20 loops on &lt;code&gt;KXATPCHALLENGERMATCH-26NOV09AGUSAN-AGU&lt;/code&gt; (ask $0.43, max price $0.45), the bot logged one &lt;code&gt;dry_run&lt;/code&gt;, then &lt;code&gt;holding&lt;/code&gt; 19 times. A restart kept the paper order and the per-minute timestamps. The kill file, &lt;code&gt;BOT_KILL=1&lt;/code&gt;, the exposure cap and the per-minute limit (seeded, then a restart) all blocked as they should. A closed market was skipped. &lt;code&gt;cancel_all.py&lt;/code&gt; with no keys exited 1 and said nothing was sent.&lt;/p&gt;

&lt;p&gt;For context, the Oct 25 pass is where the big bugs were: the first prompt proposed a new buy on every loop (38 orders in 13 minutes on a $0.13 ask, and 38 more after a restart), counted exposure from orders sent in memory, had a daily loss limit fixed at 0, missed the key in &lt;code&gt;~/.config/kalshi/&lt;/code&gt; in its deny rules, and searched 1 page of 217 for tickers. None of that came back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke on live demo data
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A 429 ignored Retry-After.&lt;/strong&gt; Through a local proxy that answered 429 with &lt;code&gt;Retry-After: 5&lt;/code&gt;, the bot retried after 0.5, 1 and 2 seconds and gave up in 3.6 s. The server asked for five seconds; the bot spent its whole retry budget before the first five were up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An HTML 200 was a cryptic crash.&lt;/strong&gt; An HTML maintenance page with status 200 logged &lt;code&gt;JSONDecodeError: Expecting value: line 1 column 1&lt;/code&gt;. That error tells a beginner nothing about what happened. A dropped connection wasn't retried at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A typo'd ticker looped forever.&lt;/strong&gt; It logged a 404 every 15 seconds, with no end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A paper order outlived its market.&lt;/strong&gt; In a dry run, an order on a market that later closed stayed in &lt;code&gt;state/paper.json&lt;/code&gt;. The one-position rule saw it and held forever, and the exposure check counted it. A beginner watching the log would see a calm "holding" line while their cap quietly filled up with a contract that could never exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ctrl-C and &lt;code&gt;| head&lt;/code&gt; crashed.&lt;/strong&gt; Ctrl-C printed a traceback. With &lt;code&gt;python bot.py | head -1&lt;/code&gt;, the closed pipe was caught by the holding check and logged as &lt;code&gt;holding check error: BrokenPipeError&lt;/code&gt;, then the bot crashed anyway. With output sent to a file, nothing showed up until the bot exited, because Python buffers it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;find_ticker.py printed 8,046 lines.&lt;/strong&gt; It read all 243 pages of open demo markets in about 59 seconds and listed every market with an ask. One had a close time in March 2026, already past. 66 had less than one contract on offer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed in the prompt
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every request gets &lt;code&gt;timeout=(5, 10)&lt;/code&gt;. Reads and cancel-all retry up to 2 times on 429, 5xx, timeouts and dropped connections. On 429 the bot waits the &lt;code&gt;Retry-After&lt;/code&gt; seconds if sent (cap 8 s), else 1 s then 2 s.&lt;/li&gt;
&lt;li&gt;An order POST is never retried.&lt;/li&gt;
&lt;li&gt;A reply that isn't JSON is a one-line error: the status code plus the first 80 characters with HTML tags stripped.&lt;/li&gt;
&lt;li&gt;Kalshi's &lt;code&gt;error.code&lt;/code&gt; is logged as-is. Since Nov 5, 2026 an order on a halted or closed market returns &lt;code&gt;market_inactive&lt;/code&gt; (it used to be &lt;code&gt;market_closed&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A 404 on &lt;code&gt;GET /markets/{ticker}&lt;/code&gt; stops the bot with "ticker not found, run find_ticker.py".&lt;/li&gt;
&lt;li&gt;If the market isn't &lt;code&gt;active&lt;/code&gt; and there's a paper order for it, the bot drops it and logs &lt;code&gt;paper_dropped&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Every decision is printed with &lt;code&gt;flush=True&lt;/code&gt;, outside any try block that catches errors. Ctrl-C exits with "Stopped (Ctrl-C)." and no traceback; a closed pipe exits quietly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;find_ticker.py&lt;/code&gt; takes &lt;code&gt;--max-price&lt;/code&gt; and &lt;code&gt;--top&lt;/code&gt; (default 25), skips markets whose close time has passed or whose ask size is below 1, shows page progress on stderr, and ends with the usable count.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Drill results after the fix
&lt;/h2&gt;

&lt;p&gt;We rebuilt from the updated prompt plus the follow-up; 20 tests pass. The same loop, then the drills, from the recipe page (timestamps and IDs trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"decision": "dry_run", "body": {... "price": "0.4300", ...}, "exposure": 0, "daily_loss": "daily loss not tracked"}
{"decision": "holding", "ask": 0.43}
--- 429 with Retry-After: 5 (waited 5 s twice, 10.2 s total)
{"decision": "error", "error": "GET /markets/...: HTTP 429 too_many_requests too many requests"}
--- HTML page with status 200
{"decision": "error", "error": "GET /markets/...: HTTP 200, not JSON: 'Maintenance'"}
--- typo ticker
Ticker KXATPCHALLENGERMATCH-26NOV09AGUSAN-AGUX not found on demo. Run find_ticker.py and fix config.toml.
--- paper order on a market that closed
{"decision": "paper_dropped", "reason": "market status closed"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of the drills:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dropped connection and hung server: one line each. A hung server costs up to about 33 seconds per loop (three 10-second tries plus waits) before the bot logs "timed out" and moves on.&lt;/li&gt;
&lt;li&gt;Typo ticker: stops with exit code 1.&lt;/li&gt;
&lt;li&gt;Ctrl-C: exit code 130, no traceback.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;python bot.py | head&lt;/code&gt;: exit code 0, quiet.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;find_ticker.py --max-price 0.45&lt;/code&gt;: 3,719 usable markets, printed as the 25 soonest to close instead of 8,046 lines.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open an empty folder in Claude Code. Paste the prompt from the recipe page, with the Safety Pack rules file at the bottom.&lt;/li&gt;
&lt;li&gt;Run step 0 only. Approve the write to &lt;code&gt;.claude/settings.json&lt;/code&gt;, then check the deny rules, &lt;code&gt;/status&lt;/code&gt; and &lt;code&gt;/sandbox&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Let it build step 1, and ask it to fetch the Kalshi docs pages first so it uses the current V2 order endpoint.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;python find_ticker.py --max-price 0.50&lt;/code&gt; and pick a ticker that doesn't close today. Demo books are thin.&lt;/li&gt;
&lt;li&gt;Read &lt;code&gt;broker.py&lt;/code&gt; and &lt;code&gt;risk.py&lt;/code&gt; line by line before running anything.&lt;/li&gt;
&lt;li&gt;Dry-run with no keys. &lt;code&gt;touch KILL&lt;/code&gt; and confirm the next loop sends nothing. Delete &lt;code&gt;state/paper.json&lt;/code&gt; to start over.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Built this before Nov 9? The recipe page has a follow-up prompt for the same project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Update this Kalshi demo bot. Read the current Kalshi docs and
changelog (https://docs.kalshi.com/changelog) first.
1. broker.py: every request gets timeout=(5, 10). Reads and cancel-all
   retry up to 2 times on 429, 5xx, timeouts and dropped connections.
   On 429 wait the Retry-After seconds if sent (cap 8), else 1 s then
   2 s. Never retry an order POST.
2. A reply that isn't JSON (like an HTML maintenance page) is a
   one-line error with the status code and the first 80 characters of
   text with HTML tags stripped, not a traceback. For Kalshi errors,
   log error.code as-is (since Nov 5, 2026 a halted market returns
   market_inactive, formerly market_closed).
3. bot.py: if GET /markets/{ticker} returns 404, stop with "ticker not
   found, run find_ticker.py". If the market isn't "active" and there's
   a paper order for it in state/paper.json, drop it and log
   "paper_dropped".
4. Print each decision with flush=True, and don't print inside a try
   block that catches errors. Ctrl-C exits with "Stopped (Ctrl-C)." and
   no traceback. A closed pipe (python bot.py | head) exits quietly.
5. find_ticker.py: add --max-price and --top (default 25). Skip markets
   whose close_time has passed or whose yes_ask_size_fp is below 1.
   Show page progress on stderr and end with the count of usable
   markets.
6. Tests with a fake HTTP session: Retry-After honored and capped,
   HTML 200, timeout retried then one line, order POST not retried,
   error code kept, 404 stops, paper order dropped.
Show me the diff before running anything.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you built it before Oct 25, paste the Oct 25 follow-up from the page first, then this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;The page's verified line, word for word: &lt;em&gt;Verified working on live data in paper mode, Nov 9, 2026. Dry run on live Kalshi demo data with no keys, plus local fault drills. Signed demo orders, exposure and daily loss from a real account need your own demo keys and weren't run.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So this run proves plumbing on public demo data: retries, timeouts, error lines, the one-position rule, the kill switch, the caps and the paper ledger. Exposure, daily loss and the holding check against a real demo account are covered by tests with a fake HTTP session, not by a signed run. You'll need your own demo keys for that, and you should run the bot with them in your own terminal, not through Claude. It proves nothing about a strategy. Claude can build the plumbing; it can't find you an edge.&lt;/p&gt;

&lt;p&gt;The full recipe, with the complete prompt, both follow-ups, the safety checklist and every doc we checked, is at &lt;strong&gt;&lt;a href="https://botcookery.com/recipes/claude-trading-bot/" rel="noopener noreferrer"&gt;botcookery.com/recipes/claude-trading-bot&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;API changes and new field notes: &lt;a class="mentioned-user" href="https://dev.to/botcookery"&gt;@botcookery&lt;/a&gt; on X (&lt;a href="https://x.com/botcookery" rel="noopener noreferrer"&gt;https://x.com/botcookery&lt;/a&gt;)&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Our Grok Kalshi paper bot ignored Retry-After and treated a login page as JSON</title>
      <dc:creator>BotCookery</dc:creator>
      <pubDate>Fri, 06 Nov 2026 15:48:03 +0000</pubDate>
      <link>https://dev.to/botcookery/our-grok-kalshi-paper-bot-ignored-retry-after-and-treated-a-login-page-as-json-26ad</link>
      <guid>https://dev.to/botcookery/our-grok-kalshi-paper-bot-ignored-retry-after-and-treated-a-login-page-as-json-26ad</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botcookery.com/recipes/grok-trading-bot/" rel="noopener noreferrer"&gt;BotCookery&lt;/a&gt;, free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every log line and count below is from the Nov 5, 2026 rebuild against Kalshi's live public demo data in paper mode: no demo keys, and no order was sent. Nothing here is financial advice. Paper and demo only.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;People keep asking whether Grok can build a trading bot. The useful answer is narrower: an AI coding agent can scaffold a Kalshi &lt;strong&gt;demo&lt;/strong&gt; bot whose spending limits live in code, whose research step only sees public market text, and that you can stop with one command. The recipe on BotCookery is that prompt. On the morning of Nov 5, 2026 (ET) we pasted the published one-shot into a clean folder, built it by hand, and ran it in paper mode against Kalshi's live public demo. The Oct 25 bookkeeping held. The HTTP edge did not.&lt;/p&gt;

&lt;p&gt;The first fault we drilled was a 429 with &lt;code&gt;Retry-After: 3&lt;/code&gt;. The bot retried after about half a second of homemade backoff. That is the finding this post leads with, because it is the kind of bug that looks fine in a happy-path run and then burns you the first time the venue asks you to wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the recipe is
&lt;/h2&gt;

&lt;p&gt;The page title on the site is a how-to for building a Kalshi paper bot with Grok and spending limits. Under the hood it is the same shape as our Claude and ChatGPT starters: the agent writes the code; &lt;strong&gt;you&lt;/strong&gt; set the rules and the dollar caps. Paper mode is the default. Public market reads hit &lt;code&gt;https://external-api.demo.kalshi.co/trade-api/v2&lt;/code&gt; with no key. Demo orders need keys, &lt;code&gt;PAPER_MODE=0&lt;/code&gt;, &lt;code&gt;--live&lt;/code&gt;, and a typed confirmation; we had none of that, so nothing was signed.&lt;/p&gt;

&lt;p&gt;Limits sit in &lt;code&gt;limits.json&lt;/code&gt; and &lt;code&gt;risk.py&lt;/code&gt;, not in the agent's instructions. An agent can misread a prompt; it cannot skip a check it does not control. Create Order V2 is &lt;code&gt;POST /portfolio/events/orders&lt;/code&gt; with &lt;code&gt;side&lt;/code&gt;, &lt;code&gt;count&lt;/code&gt;, &lt;code&gt;price&lt;/code&gt;, &lt;code&gt;time_in_force&lt;/code&gt; (&lt;code&gt;fill_or_kill&lt;/code&gt;, &lt;code&gt;good_till_canceled&lt;/code&gt;, or &lt;code&gt;immediate_or_cancel&lt;/code&gt;) and &lt;code&gt;self_trade_prevention_type&lt;/code&gt;. Cancel-all is &lt;code&gt;DELETE /portfolio/events/orders&lt;/code&gt;. Order groups cap matched contracts over a rolling 15 seconds, so the bot must create one group and reuse it. The optional research step posts only public title, rules text, and prices to xAI's Chat Completions endpoint; its output is logged and never changes the bid. We left &lt;code&gt;research_enabled&lt;/code&gt; false and never called it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened when we rebuilt it from this prompt
&lt;/h2&gt;

&lt;p&gt;On Nov 5, 2026 we pasted this page's one-shot prompt into a clean folder, built it by hand and ran it in paper mode against Kalshi's live public demo data that morning (ET). No demo keys, so nothing was signed or sent. The Oct 25 bookkeeping fixes (one order group, paper IOC fill, held-ticker skip, settle-first daily loss, cancel_all leaves filled exposure alone) held up. What broke was the HTTP edge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retry-After was ignored.&lt;/strong&gt; A 429 with &lt;code&gt;Retry-After: 3&lt;/code&gt; retried after ~0.5 s of homemade backoff. Reads now sleep the header value (cap 8 s) when it is present.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTML 200 printed a JSON decode error.&lt;/strong&gt; A gateway login page as a 200 became &lt;code&gt;Expecting value: line 1 column 1&lt;/code&gt;. Now: &lt;code&gt;fetch failed: response was not JSON: gateway login&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hung host chewed the clock.&lt;/strong&gt; Default timeouts were generous and retries stacked; against a hung proxy the process sat until an outer 55 s kill with no useful line. Now: &lt;code&gt;timeout=(5, 10)&lt;/code&gt;, at most 2 retries, then &lt;code&gt;fetch failed: timed out&lt;/code&gt; (~16 s in our drill).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;503 HTML leaked tags into the error.&lt;/strong&gt; Stripped now (&lt;code&gt;503 Service Unavailable&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo books are thinner than the Oct 25 sample suggested.&lt;/strong&gt; Around 8:57 AM ET we scanned 5,000 open markets (&lt;code&gt;mve_filter=exclude&lt;/code&gt;) and found 56 with a real YES ask, 18 of them ≤ $0.15. Around 9:04 AM the first 1,000 had 13 real asks and zero ≤ $0.15. Pick the ticker right before you run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Oct 25 condensed (still true): a new order group every run meant the server-side 15 s cap never bit; cancel_all had been zeroing exposure for IOC fills; paper reserved the limit price instead of filling at the book; the bot re-bought the same ticker every run; &lt;code&gt;daily_loss_usd&lt;/code&gt; was never written. Those are all in the one-shot now.&lt;/p&gt;

&lt;p&gt;From the fresh rebuild this morning (16 tests pass), 8:58–9:03 AM ET:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;08:58:39 KXNFLRECYDS-26NOV08CINATL-ATLAHOOPER81-40 (Austin Hooper: 40+ receiving yards) ask 0.0600 x50.00, limit 0.1500: PAPER IOC bid 1 @ 0.1500 -&amp;gt; filled 1.00 @ 0.0600 fee 0.01; exposure $0.070000, group paper-6cfc6744
08:58:39 KXNFLRECYDS-26NOV08CINATL-ATLAHOOPER81-40 … already hold 1.00, skip
KILL is on. No order, no order group.
{'paper_cancelled_resting': 0}
positions kept: {'KXNFLRECYDS-26NOV08CINATL-ATLAHOOPER81-40': {…}} exposure $0.070000
08:58:50 settled KXLIGAPROMATCH-26NOV050700LOBALO-LOB: no, pnl -0.13
08:58:51 KXNFLTD-26NOV08DENCAR-CARJCOKER18-3 (Jalen Coker: 3+ touchdowns) ask 0.0100 x25.00, limit 0.1500: PAPER IOC bid 1 @ 0.1500 -&amp;gt; filled 1.00 @ 0.0100 fee 0.01; exposure $0.090000, group paper-6cfc6744
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The settlement line is a drill: we injected a test position on a demo market Kalshi had already finalized as "no", and the bot booked a $0.13 loss toward the daily limit. The second buy reused the first run's order group. After the HTTP fixes, fault drills against a local proxy: 429+Retry-After slept ~3 s; HTML 200 and 503 printed one clean line; hang ended with &lt;code&gt;timed out&lt;/code&gt; in 16.5 s.&lt;/p&gt;

&lt;p&gt;What we still can't test without demo keys: real demo fills, order-group rejection, signed cancels. Without an xAI key: the Grok research reply. &lt;code&gt;kalshi.com/fee-schedule&lt;/code&gt; returned 429 during this check; the taker fee formula in the prompt (&lt;code&gt;ceil&lt;/code&gt; to the cent of &lt;code&gt;0.07 * count * price * (1 - price)&lt;/code&gt;) still matches what we charge in paper. Docs pages we rely on were 200; demo and elections hosts both answered. Changelog Nov 5: &lt;code&gt;market_closed&lt;/code&gt; → &lt;code&gt;market_inactive&lt;/code&gt; on order errors; KXRFQ series excluded from unscoped lists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Retry-After matters more than the happy path
&lt;/h2&gt;

&lt;p&gt;Before the fix, a single 429 with &lt;code&gt;Retry-After: 3&lt;/code&gt; still retried after ~0.5 s. A drill that always returned 429 took about 22.6 s across five long retries and then printed a noisy client error. After the fix: when the header is present, sleep that many seconds (cap 8); at most two retries; timeout &lt;code&gt;(5, 10)&lt;/code&gt;. The same 429-once drill slept about 3 s and finished in 3.84 s. An always-429 drill now fails in 1.51 s with one clear line: &lt;code&gt;fetch failed: 429 {"error":"too many requests"}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is the difference between "we rate-limit politely" and "we look like a broken client." Homemade exponential backoff is fine when the server is silent. When the server tells you when to come back, ignore that number and you are guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTML 200 and the hung host
&lt;/h2&gt;

&lt;p&gt;A gateway login page served as HTTP 200 is not a JSON body. The pre-fix client raised &lt;code&gt;Expecting value: line 1 column 1&lt;/code&gt; — the classic &lt;code&gt;JSONDecodeError&lt;/code&gt; surface. After the fix the same drill prints &lt;code&gt;fetch failed: response was not JSON: gateway login&lt;/code&gt;. A 503 that returned HTML used to leak tags into the error string; it now collapses to &lt;code&gt;503 Service Unavailable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The hung-host drill is the one that wastes a whole morning if you leave generous defaults in place. Against a proxy that never answered, the old client sat until an outer 55 s kill with empty stdout. The fixed client uses &lt;code&gt;timeout=(5, 10)&lt;/code&gt;, at most two retries, then &lt;code&gt;fetch failed: timed out&lt;/code&gt; in about 16.5 s in our drill. You still will not get an instant Ctrl-C mid-request — that remains a noted limit — but you get a useful line and a bounded wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thin demo books are a beginner gotcha
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;yes_ask_dollars&lt;/code&gt; of &lt;code&gt;"0.0000"&lt;/code&gt; or &lt;code&gt;"1.0000"&lt;/code&gt; means nobody is offering YES. Treat both as no ask. Never compute "100 − X" on dollar prices; the book returns bids only, and the implied YES ask is &lt;code&gt;$1.00&lt;/code&gt; minus the highest NO bid when you have to derive it from the book.&lt;/p&gt;

&lt;p&gt;On Nov 5 around 8:57 AM ET, a scan of 5,000 open markets with &lt;code&gt;mve_filter=exclude&lt;/code&gt; found 56 with a real YES ask, 18 of them ≤ $0.15. Around 9:04 AM the first 1,000 open markets had 13 real asks and zero ≤ $0.15. The ticker we used at 8:58 (&lt;code&gt;KXNFLRECYDS-26NOV08CINATL-ATLAHOOPER81-40&lt;/code&gt;, Austin Hooper 40+ receiving yards) had ask &lt;code&gt;0.0600&lt;/code&gt; x50 against a &lt;code&gt;0.1500&lt;/code&gt; limit. A few minutes later the cheap end of the book had moved. Pick the ticker right before you run.&lt;/p&gt;

&lt;p&gt;Unscoped &lt;code&gt;GET /markets&lt;/code&gt; also skips on-demand KXRFQ series as of Kalshi's Nov 5, 2026 changelog. If your scan looks emptier than you expect, check the filter and the series, not only your pagination loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  market_inactive (was market_closed)
&lt;/h2&gt;

&lt;p&gt;As of that same changelog, orders on a halted or closed market return &lt;code&gt;market_inactive&lt;/code&gt; (it used to say &lt;code&gt;market_closed&lt;/code&gt;). Market lifecycle &lt;code&gt;status&lt;/code&gt; values like &lt;code&gt;active&lt;/code&gt; / &lt;code&gt;finalized&lt;/code&gt; are unchanged; this bot still only bids when status is &lt;code&gt;active&lt;/code&gt;. If your error handler still keys on the old string, a halt looks like an unknown failure instead of a clean skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paper run numbers, verbatim from the rebuild
&lt;/h2&gt;

&lt;p&gt;The 8:58 ET paper pass filled one contract on the Hooper ticker at &lt;code&gt;0.0600&lt;/code&gt; with a &lt;code&gt;$0.01&lt;/code&gt; fee, booked &lt;code&gt;$0.07&lt;/code&gt; exposure under group &lt;code&gt;paper-6cfc6744&lt;/code&gt;, skipped the held ticker on the next run, refused under KILL without creating a group, left exposure alone after &lt;code&gt;cancel_all&lt;/code&gt; (&lt;code&gt;paper_cancelled_resting: 0&lt;/code&gt;), settled the injected Ligamento position as &lt;code&gt;no&lt;/code&gt; with &lt;code&gt;pnl -0.13&lt;/code&gt;, then filled a second ticker (&lt;code&gt;KXNFLTD-26NOV08DENCAR-CARJCOKER18-3&lt;/code&gt; at ask &lt;code&gt;0.0100&lt;/code&gt;) reusing the &lt;strong&gt;same&lt;/strong&gt; order group and ending at &lt;code&gt;$0.09&lt;/code&gt; exposure. Sixteen offline tests passed after the HTTP hardening. A second paper pass on the fixed build around 9:02 ET repeated the Hooper fill under a new paper group id, held skip, kill, and cancel-keeps-exposure.&lt;/p&gt;

&lt;p&gt;None of those fills left the box. Paper mode reads the live public demo book and simulates an immediate-or-cancel fill locally. Real demo fills, order-group rejection, and signed cancels remain untested without keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built this before Nov 5?
&lt;/h2&gt;

&lt;p&gt;Paste this follow-up into the same project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Update the Kalshi demo bot with these HTTP and docs fixes. Stay in
paper mode; send nothing to the network except public market reads.

1. Public GETs: timeout=(5, 10), at most 2 retries. On 429/5xx use
   bounded exponential backoff. If the response has Retry-After, sleep
   that many seconds instead (cap 8).
2. Never print a traceback for HTTP or JSON failures. One line only:
   "fetch failed: …". HTML or non-JSON 200 -&amp;gt; "response was not JSON: …"
   (strip tags, truncate). 503 HTML -&amp;gt; "503 Service Unavailable".
3. Hang/timeout -&amp;gt; "fetch failed: timed out" after the retries, then exit.
4. Catch BrokenPipeError around main (exit 0) so `| head` is quiet.
   KeyboardInterrupt -&amp;gt; one line, exit 130.
5. Docs: orders on a halted/closed market return market_inactive (Kalshi
   changelog Nov 5, 2026; was market_closed). Unscoped GET /markets skips
   on-demand KXRFQ series. Fee formula unchanged
   (ceil-cent 0.07 * count * price * (1 - price)); kalshi.com/fee-schedule
   may 429 — use the formula in the prompt.
6. Beginner gotcha: demo books are thin. When picking a ticker, scan
   status=open&amp;amp;mve_filter=exclude until yes_ask_dollars is strictly
   between 0 and 1 ("0.0000" and "1.0000" mean no ask).
7. Keep Oct 25 behaviour: one reused order group, paper IOC fill at the
   ask with fee, held-ticker skip, settle-first daily loss, cancel_all
   does not clear filled exposure.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Starting fresh? The full one-shot, the Oct 25 follow-up, the safety checklist, and every Kalshi doc we checked are on the recipe page. Run paper first with no keys. Touch &lt;code&gt;KILL&lt;/code&gt;, confirm it refuses, then run &lt;code&gt;cancel_all&lt;/code&gt; and confirm filled exposure stays put.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is and isn't
&lt;/h2&gt;

&lt;p&gt;The page's verified line, word for word: &lt;em&gt;Verified working on live data in paper mode, Nov 5, 2026. Ran live in paper mode against Kalshi's public demo market data on the morning of Nov 5 (ET), for several runs plus kill-switch, cancel-all, settlement and HTTP fault drills. We had no demo keys, so no signed request was sent and no demo order was placed; real demo fills and order-group rejections are untested. The optional research step was not called (research_enabled false; no xAI API call). Settlement was drilled with a test position on a demo market that had already been finalized.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;These runs prove plumbing: retries, timeouts, paper IOC math, held-ticker skip, settle-first daily loss, kill, cancel-all. They do not prove a strategy. Demo books are thin and change by the minute; Kalshi notes that demo prices and behavior may differ from production. If you turn the research step on later, treat the model's probabilities as advisory. Kalshi's Developer Agreement allows API use for your own trading, not for running a bot for other people. Be wary of any account offering to "run your bot" or sell "Grok signals."&lt;/p&gt;

&lt;p&gt;The full recipe — prompt, steps, FAQs on thin books and &lt;code&gt;market_inactive&lt;/code&gt;, and the dated rebuild write-up — is at &lt;strong&gt;&lt;a href="https://botcookery.com/recipes/grok-trading-bot/" rel="noopener noreferrer"&gt;botcookery.com/recipes/grok-trading-bot&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;API changes and new field notes: &lt;a class="mentioned-user" href="https://dev.to/botcookery"&gt;@botcookery&lt;/a&gt; on X (&lt;a href="https://x.com/botcookery" rel="noopener noreferrer"&gt;https://x.com/botcookery&lt;/a&gt;)&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>tutorial</category>
      <category>ai</category>
    </item>
    <item>
      <title>Our Polymarket-Kalshi arbitrage monitor found one 2¢ gap. After real fees it lost 1.4¢</title>
      <dc:creator>BotCookery</dc:creator>
      <pubDate>Thu, 05 Nov 2026 14:54:07 +0000</pubDate>
      <link>https://dev.to/botcookery/our-polymarket-kalshi-arbitrage-monitor-found-one-2c-gap-after-real-fees-it-lost-14c-41b7</link>
      <guid>https://dev.to/botcookery/our-polymarket-kalshi-arbitrage-monitor-found-one-2c-gap-after-real-fees-it-lost-14c-41b7</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botcookery.com/recipes/polymarket-kalshi-arbitrage-alert-monitor/" rel="noopener noreferrer"&gt;BotCookery&lt;/a&gt;, free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every number and log line below comes from read-only runs against the live Kalshi and Polymarket US public APIs on the afternoon of Oct 25, 2026. The monitor has no order code at all. Nothing here is financial advice.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Risk-free arbitrage between Polymarket and Kalshi" is one of the most common bot pitches around. The idea is simple: if YES on one venue plus NO on the other costs less than $1.00, one of them has to pay $1.00, so you lock in the difference.&lt;/p&gt;

&lt;p&gt;It only works if two things are true at once: the combined cost is under $1.00 &lt;strong&gt;after fees&lt;/strong&gt;, and both contracts settle on &lt;strong&gt;exactly the same outcome&lt;/strong&gt;. Our recipe builds an alerts-only monitor that measures both, for market pairs you map by hand. It never places, previews or cancels an order.&lt;/p&gt;

&lt;p&gt;On Oct 25 we pasted the Oct 22 version of the prompt into a clean folder, let an AI coding tool build it, and ran it read-only on three real pairs: Magic at Knicks (NBA), Chiefs at Seahawks (NFL), and the 83-84°F bucket for Miami's Oct 26 high temperature. The skip rules, the stale-date rule and the "price is always YES" math were right. Six other things weren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: the recommended Kalshi host answered 403
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;external-api.kalshi.com&lt;/code&gt; is the host Kalshi's docs recommend. From our network it answered 403, so the first run skipped all three pairs and reported nothing. The other documented host, &lt;code&gt;api.elections.kalshi.com&lt;/code&gt;, worked fine.&lt;/p&gt;

&lt;p&gt;The fix is boring but necessary: try the recommended host, switch once on a 403, and keep whichever works.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Kalshi https://external-api.kalshi.com/trade-api/v2 answered 403; switching to https://api.elections.kalshi.com/trade-api/v2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Problem 2: placeholder fees made a losing trade look break-even
&lt;/h2&gt;

&lt;p&gt;The first build used a flat 1¢ per contract per venue as a placeholder. That doesn't match either venue's formula. Both fees scale with &lt;code&gt;p × (1 - p)&lt;/code&gt;, so they're largest near 50¢:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kalshi taker:&lt;/strong&gt; &lt;code&gt;fee_multiplier × 0.07 × C × P × (1 - P)&lt;/code&gt;, rounded &lt;strong&gt;up&lt;/strong&gt; to the cent. The multiplier comes from &lt;code&gt;GET /series/{series_ticker}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polymarket US taker:&lt;/strong&gt; &lt;code&gt;0.0695 × C × p × (1 - p)&lt;/code&gt;, rounded to the cent (banker's rounding).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On these books the real fees were 1.4-1.8¢ per contract on each leg. Here's the old build's first cycle at 3:41 PM ET:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;!!! WARNING: fees.yaml has placeholder values. THESE EDGES ARE NOT REAL. !!!
Magic @ Knicks, Oct 25 (Orlando wins) [K YES + PM short] cost 0.2900+0.7200=1.0100 edge_after_fees -0.0300 depth 261080
Magic @ Knicks, Oct 25 (Orlando wins) [K NO + PM long] cost 0.7200+0.2900=1.0100 edge_after_fees -0.0300 depth 12550
Chiefs @ Seahawks, Oct 25 (Seattle ticker vs Chiefs-long) [K YES + PM long] cost 0.5900+0.4150=1.0050 edge_after_fees -0.0250 depth 1504150
Chiefs @ Seahawks, Oct 25 (Seattle ticker vs Chiefs-long) [K NO + PM short] cost 0.4200+0.5875=1.0075 edge_after_fees -0.0275 depth 651638
Miami high Oct 26, 83-84F [K YES + PM short] cost 0.6300+0.4100=1.0400 edge_after_fees -0.0600 depth 37
Miami high Oct 26, 83-84F [K NO + PM long] cost 0.3800+0.6000=0.9800 edge_after_fees +0.0000 depth 12
SKIP Stale test pair: rules_checked_on is 24 days old (&amp;gt;14)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last Miami line is the only gross gap we saw all afternoon: Kalshi NO at $0.38 plus Polymarket US YES at $0.60 = $0.98. With placeholders it looks like a wash. Now the real math for 12 contracts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kalshi: 0.07 × 12 × 0.38 × 0.62 = $0.198, rounded up to &lt;strong&gt;$0.20&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Polymarket US: 0.0695 × 12 × 0.6008 × 0.3992 = &lt;strong&gt;$0.20&lt;/strong&gt; (0.6008 is the 12-contract average, see problem 3)&lt;/li&gt;
&lt;li&gt;Total per contract: 0.38 + 0.6008 + 0.40 / 12 = 1.0142&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the edge is &lt;strong&gt;-1.4¢ a contract&lt;/strong&gt;. The one "arbitrage" of the afternoon loses money before you even think about timing. The new prompt computes both published formulas per leg, at the size you'd actually trade, keeps the coefficients in &lt;code&gt;fees.yaml&lt;/code&gt; with a &lt;code&gt;checked_on&lt;/code&gt; date, and warns when that date is over 30 days old.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: "depth" meant the top level only
&lt;/h2&gt;

&lt;p&gt;Polymarket US showed 11.66 contracts at $0.60. Twelve contracts don't fit in 11.66, so the real 12-contract average was $0.6008. A few minutes later someone had taken most of that offer, and the same 12 contracts averaged $0.6109.&lt;/p&gt;

&lt;p&gt;The monitor now walks each book across levels, volume-weighted, for the size you'd trade, and logs "too thin" if either side can't fill it. One more trap: in Polymarket US's &lt;code&gt;/bbo&lt;/code&gt; response, &lt;code&gt;askDepth&lt;/code&gt; and &lt;code&gt;bidDepth&lt;/code&gt; are not contract counts. Take sizes from &lt;code&gt;/book&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: the Miami gap wasn't an arbitrage anyway
&lt;/h2&gt;

&lt;p&gt;Even with a positive edge, that pair would have been a bet, not an arbitrage. On Oct 25 Kalshi's Miami high-temperature market settled "according to The Weather Company". Polymarket US's settled on the National Weather Service's daily climate report. Same city, same day, same bucket, two different sources. They can disagree by a degree, and one degree moves a bucket.&lt;/p&gt;

&lt;p&gt;The NFL pair differed too. Both venues paid $0.50 on a tie, but on a postponement Kalshi went to a fair price after 48 hours while Polymarket US waited up to two weeks.&lt;/p&gt;

&lt;p&gt;The old prompt had a free-text &lt;code&gt;notes&lt;/code&gt; field, which let us type "same bucket, same day" and move on. The new one makes you prove it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each pair gets &lt;code&gt;kalshi_source&lt;/code&gt;, &lt;code&gt;polymarket_source&lt;/code&gt;, &lt;code&gt;tie_and_delay&lt;/code&gt;, &lt;code&gt;rules_match&lt;/code&gt; (default &lt;code&gt;false&lt;/code&gt;) and &lt;code&gt;rules_hash&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--show-rules&lt;/code&gt; prints Kalshi's &lt;code&gt;rules_primary&lt;/code&gt;, &lt;code&gt;rules_secondary&lt;/code&gt; and the series &lt;code&gt;settlement_sources&lt;/code&gt; next to the Polymarket US description, with one sha256 of the combined text.&lt;/li&gt;
&lt;li&gt;A pair is skipped unless &lt;code&gt;rules_match&lt;/code&gt; is true &lt;strong&gt;and&lt;/strong&gt; the live hash still equals the saved one. If either venue edits its rules, the pair stops until you re-read them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We tested that last part by changing one saved fingerprint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SKIP Chiefs @ Seahawks, Oct 25 (Seattle): live rules hash 4acddea46b19a75e != saved 0000000000000000; re-read with --show-rules
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Problem 5: it couldn't tell you how long a gap lasted
&lt;/h2&gt;

&lt;p&gt;The first build would have re-alerted every 30 seconds while a gap stayed open and never logged when it closed. Gap duration is the number that matters most if you ever wanted to act by hand, so now it alerts once and logs a &lt;code&gt;CLOSED&lt;/code&gt; line with how many seconds it lasted and the best edge seen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 6: piped output stayed empty
&lt;/h2&gt;

&lt;p&gt;Our first 100-second run, piped through &lt;code&gt;tee&lt;/code&gt;, printed nothing. Python buffers stdout when it isn't a terminal. Every line now prints with &lt;code&gt;flush=True&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One near-miss: which end of Kalshi's book is the best bid
&lt;/h2&gt;

&lt;p&gt;Kalshi's &lt;code&gt;GET /markets/{ticker}/orderbook&lt;/code&gt; returns &lt;strong&gt;bids only&lt;/strong&gt;, as &lt;code&gt;[price, size]&lt;/code&gt; pairs sorted ascending, so the best bid is the &lt;strong&gt;last&lt;/strong&gt; element. The YES ask is $1 minus the best NO bid. Our build happened to get this right, but the old prompt never said it, and taking the first element gives you a $0.99 ask. The new prompt spells it out and has a test for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  After the fixes
&lt;/h2&gt;

&lt;p&gt;Fresh rebuild from the new prompt, 9 tests passing, 3:46 PM ET, 10 contracts, the real pairs file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Kalshi https://external-api.kalshi.com/trade-api/v2 answered 403; switching to https://api.elections.kalshi.com/trade-api/v2
Magic @ Knicks, Oct 25 (Orlando wins) [K YES + PM short] x10: K 0.2900 fee $0.15 | PM 0.7200 fee $0.14 | edge -0.0390/contract
Magic @ Knicks, Oct 25 (Orlando wins) [K NO + PM long] x10: K 0.7200 fee $0.15 | PM 0.2900 fee $0.14 | edge -0.0390/contract
SKIP Chiefs @ Seahawks, Oct 25 (Seattle): rules_match is not true
SKIP Miami high Oct 26, 83-84F: rules_match is not true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To check the alert path and the math, we ran a copy with every pair forced to &lt;code&gt;rules_match: true&lt;/code&gt;, 12 contracts and a deliberately negative margin so alerts would fire (Miami lines, 3:48-3:51 PM ET):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ALERT Miami high Oct 26, 83-84F [K NO + PM long] x12: K 0.3800 fee $0.20 | PM 0.6008 fee $0.20 | edge -0.0142/contract
CLOSED Miami high Oct 26, 83-84F [K NO + PM long] after 73s, best edge -0.0142 (edge -0.0242)
Miami high Oct 26, 83-84F [K NO + PM long] x12: K 0.3800 fee $0.20 | PM 0.6109 fee $0.20 | edge -0.0242/contract
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole story of the afternoon in three lines: the only gap was negative after fees, it got worse when the offer was taken, and the monitor logged exactly how long it lasted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built this before Oct 25? The follow-up prompt
&lt;/h2&gt;

&lt;p&gt;If you built an earlier version, paste this into the same project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Update my alerts-only arbitrage monitor with these fixes. Still no order
code of any kind; public reads only.

1. Kalshi host: try https://external-api.kalshi.com/trade-api/v2 first;
   on a 403, switch once to https://api.elections.kalshi.com/trade-api/v2
   and keep it. KALSHI_BASE still overrides both.
2. Check the Kalshi book math: arrays are bids sorted ASCENDING, so the
   best bid is the LAST element. YES ask = 1 - last NO bid, NO ask =
   1 - last YES bid. Taking the first element gives a $0.99 ask.
3. Replace the placeholder fees with the real formulas, computed per leg
   for the size you'd trade:
   Kalshi taker = fee_multiplier x 0.07 x C x P x (1-P), rounded UP to
   the cent (fee_multiplier from GET /series/{series_ticker}).
   Polymarket US taker = 0.0695 x C x p x (1-p), banker's rounding to the
   cent. Keep the coefficients in fees.yaml with a checked_on date and
   warn if it's older than 30 days.
4. Price min_size contracts by walking each book across levels
   (volume-weighted). If either side can't fill it, log "too thin".
   Take sizes from /book; /bbo askDepth and bidDepth are not sizes.
5. Add kalshi_source, polymarket_source, tie_and_delay, rules_match
   (default false) and rules_hash to each pair. Add --show-rules to print
   Kalshi rules_primary + rules_secondary + series settlement_sources
   next to the Polymarket US description, with a sha256 of both. Skip a
   pair unless rules_match is true and the live hash equals rules_hash.
6. Alert once when a gap opens; log a "closed" line with how many
   seconds it lasted and its best edge. Print with flush=True.
7. Tests for each of the above.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full build prompt, the step-by-step for mapping pairs by hand, and the safety checklist are on the &lt;a href="https://botcookery.com/recipes/polymarket-kalshi-arbitrage-alert-monitor/" rel="noopener noreferrer"&gt;recipe page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways if you're building one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use the real fee formulas at your real size.&lt;/strong&gt; Both fees peak near 50¢. A flat placeholder will show you gaps that don't exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk the book.&lt;/strong&gt; Top-of-book size is often smaller than the trade, and it moves within minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read both rulebooks for every pair, and fingerprint them.&lt;/strong&gt; "Same event" isn't the same contract if the settlement source, tie rule or postponement rule differs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log how long gaps last.&lt;/strong&gt; That, plus size and edge after fees, is the honest answer to "do arbitrage bots work?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep it private.&lt;/strong&gt; Kalshi's Developer Agreement limits API data to your own trading and bars sharing it with third parties without written permission, so no public arb dashboards or paid alert channels.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we verified, and what we didn't
&lt;/h2&gt;

&lt;p&gt;From the recipe page: &lt;em&gt;Verified working on live data in read-only mode, Oct 25, 2026. Ran read-only against the live Kalshi and Polymarket US public APIs on the afternoon of Oct 25, for several cycles on three hand-mapped pairs (one NBA game, one NFL game, one Miami temperature bucket), plus a forced-alert drill and a changed-rules drill. No gap that survived fees appeared, so no real alert fired. The Telegram and desktop alerts were not tested and no message was sent.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Fee coefficients and contract rules are as we read them on Oct 25. They change, so check both venues' fee pages and rules before you trust any number here.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>tutorial</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Our bot's $10 exposure limit held $18.60 after one restart: kill switch fixes</title>
      <dc:creator>BotCookery</dc:creator>
      <pubDate>Wed, 04 Nov 2026 14:55:12 +0000</pubDate>
      <link>https://dev.to/botcookery/our-bots-10-exposure-limit-held-1860-after-one-restart-kill-switch-fixes-34cg</link>
      <guid>https://dev.to/botcookery/our-bots-10-exposure-limit-held-1860-after-one-restart-kill-switch-fixes-34cg</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botcookery.com/recipes/trading-bot-kill-switch-position-limits/" rel="noopener noreferrer"&gt;BotCookery&lt;/a&gt;, free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every log line below is from real runs on Oct 25, 2026: live public prices from Kalshi's demo exchange and Polymarket US, with every order going to a local mock exchange. We had no keys, so no signed request was sent anywhere. Nothing here is financial advice.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our kill switch recipe is a prompt you paste into your AI coding tool inside an existing bot's folder. It asks for four things: hard limits checked in code before every order, a local KILL file, a cancel-all command for each venue, and Kalshi's server-side order groups as a last safety net.&lt;/p&gt;

&lt;p&gt;A kill switch is the one part of a bot you hope never to use, which is exactly why it tends to go untested. So on Oct 25 we tested ours. We took a small bot with no limits at all: it joins the best bid on three Kalshi demo markets and two Polymarket US markets, five contracts each, and requotes as prices move. We pasted the Oct 22 version of our prompt into its folder, let it do the retrofit, and ran it.&lt;/p&gt;

&lt;p&gt;The prompt got the KILL file and the allowlist right, and its tests passed. The limits still didn't hold. Here's what broke, what the logs showed, and how the prompt changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: a restart reset the exposure limit
&lt;/h2&gt;

&lt;p&gt;The limit was &lt;code&gt;max_open_exposure_usd = 10&lt;/code&gt;. The retrofit tracked exposure as a counter in memory, starting at zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;State&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exposure_usd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;realized_pnl_today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_times&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first run did the right thing. It placed $9.30 across four markets and stopped at the fifth, because that one would have taken it to $12.25. Then we restarted it, the way a crash loop, a supervisor or an impatient Ctrl-C would. Here's the second run at 3:58 PM ET, right after the first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;order group mock-60c841c9
15:58:15 cycle 1
  placed k:KXHIGHNY-26OCT26-B68.5 bid 0.490 x5
  placed k:KXHIGHMIA-26OCT26-B83.5 bid 0.620 x5
  placed k:KXNBAGAME-26OCT28LALLAC-LAL bid 0.470 x5
  placed p:aec-nba-orl-ny-2026-10-25 bid 0.280 x5
  blocked p:tc-temp-miahigh-2026-10-26-gte83lt84f: exposure 12.25 &amp;gt; max_open_exposure_usd
order group left in place: mock-60c841c9
closed Polymarket US positions; bye
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The counter started at zero again, so the same $9.30 went out again. After two runs the mock held $18.60 of resting bids against a $10 limit. The "blocked" line looks reassuring, and it is checking the wrong number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; open exposure is resting orders plus open positions, read from the venue (or the mock) at startup and every loop. On Kalshi that's &lt;code&gt;GET /portfolio/orders?status=resting&lt;/code&gt; and &lt;code&gt;GET /portfolio/positions&lt;/code&gt;; on Polymarket US, &lt;code&gt;GET /v1/orders/open&lt;/code&gt; and &lt;code&gt;GET /v1/portfolio/positions&lt;/code&gt;. Here's the rebuilt bot after a restart, 4:01 PM ET:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reusing order group mock-d5135215
found 4 resting orders from before; will amend, not duplicate
16:01:58 cycle 1
  exposure $9.55 / $10, daily loss $0.00 / $3
  BLOCKED p:tc-temp-miahigh-2026-10-26-gte83lt84f: create: exposure 12.50 &amp;gt; max_open_exposure_usd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It starts at $9.55, where the last run left off, not at $0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: the daily-loss stop was dead code
&lt;/h2&gt;

&lt;p&gt;The retrofit had a daily-loss stop, and it had a test, and the test passed. Here's the function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record_fill_pnl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pnl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Call with realized PnL from each closing fill. Trips the daily loss stop.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;realized_pnl_today&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;pnl&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;daily_loss&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;LIMITS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_daily_loss_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ROOT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KILL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;daily loss stop&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only caller was the test. The bot itself never called it. And even if it had, it counted realized PnL only, so an open position could lose a lot without ever tripping the stop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; PnL is realized plus unrealized, with open positions marked at the best bid. The bot saves PnL at the first loop of each local day in &lt;code&gt;state.json&lt;/code&gt;, and daily loss is that snapshot minus PnL now, computed in the main loop every cycle rather than in a fill handler. At the limit it creates KILL, runs cancel-all and logs it.&lt;/p&gt;

&lt;p&gt;To drill it, we gave a copy of the bot a $1 limit and a seeded position of 5 Miami contracts bought at $0.95. The live bid was $0.62, so the main loop marked it down on the very first cycle (4:02 PM ET):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  exposure $8.85 / $10, daily loss $1.65 / $1.0
  DAILY LOSS LIMIT HIT: wrote KILL, running cancel_all.py
[mock] kalshi after: 0 resting []
[mock] polymarket_us after: 0 open []
ALL CLEAR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;5 contracts x ($0.95 - $0.62) = $1.65. Nothing had filled or closed, which is the point: the old stop would have said $0.00.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: amends skipped every check
&lt;/h2&gt;

&lt;p&gt;Our bot requotes. When the best bid moves it doesn't cancel and re-place, it amends on Kalshi and modifies on Polymarket US. Neither path went through &lt;code&gt;check_order&lt;/code&gt;, and neither counted toward &lt;code&gt;max_orders_per_minute&lt;/code&gt;. The old prompt only asked about order &lt;em&gt;creation&lt;/em&gt;, and its test only looked there.&lt;/p&gt;

&lt;p&gt;That matters more than it sounds. A requoting bot spends most of its life amending. A per-minute cap that ignores amends can't stop a requote loop, and an exposure check that ignores amends lets a bot walk a small order up to any price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; every create, amend, modify and close calls &lt;code&gt;check_order(action, order, state)&lt;/code&gt;, and amends and modifies count toward the per-minute cap. Kalshi amend is &lt;code&gt;POST /portfolio/events/orders/{id}/amend&lt;/code&gt;, where &lt;code&gt;count&lt;/code&gt; is the new total, not a delta. Polymarket US modify is a cancel-replace. The test that walks the code with &lt;code&gt;ast&lt;/code&gt; now has to catch POSTs to any order path: create, amend, modify, batch and close.&lt;/p&gt;

&lt;p&gt;In the rebuild, we moved one resting bid to $0.60 so the bot would amend it back to the $0.62 bid. The amend went through &lt;code&gt;check_order&lt;/code&gt; and counted against exposure only for the 10-cent difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: the no-keys cancel-all only printed
&lt;/h2&gt;

&lt;p&gt;With no keys, the old &lt;code&gt;cancel_all.py&lt;/code&gt; had nothing to talk to, so it printed what it would have sent. This is the whole output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[mock] would send: GET  /trade-api/v2/portfolio/orders?status=resting
[mock] would send: DELETE /trade-api/v2/portfolio/events/orders  (signed, expect 204)
[mock] would send: GET  /trade-api/v2/portfolio/orders?status=resting
[mock] would send: POST https://api.polymarket.us/v1/orders/open/cancel {"slugs": []}
[mock] would send: GET  https://api.polymarket.us/v1/orders/open
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five lines that look like a working kill switch. Afterwards the mock still had 12 resting orders. A drill that can't fail proves nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; with no keys, the mock really cancels, and &lt;code&gt;cancel_all.py&lt;/code&gt; re-lists after cancelling, prints before and after for each venue, and exits 1 if anything is still resting. On Polymarket US the re-list matters even with keys: &lt;code&gt;canceledOrderIds&lt;/code&gt; in the response isn't proof, the follow-up &lt;code&gt;GET /v1/orders/open&lt;/code&gt; is. Here's &lt;code&gt;touch KILL&lt;/code&gt; and &lt;code&gt;python cancel_all.py&lt;/code&gt; in the rebuild:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KILL ACTIVE: no new orders. Run cancel_all.py if anything is resting.
[mock] kalshi before: 3 resting ['542f952ead4f', '528a849bdd5b', 'f8d779bab1ca']
[mock] kalshi order group mock-d5135215 triggered (reset it by hand to trade again)
[mock] kalshi after: 0 resting []
[mock] polymarket_us before: 1 open ['36638965e827']
[mock] polymarket_us canceledOrderIds: ['36638965e827']
[mock] polymarket_us after: 0 open []
ALL CLEAR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bug 5: a new Kalshi order group every run
&lt;/h2&gt;

&lt;p&gt;Kalshi order groups are the server-side layer: if fills in the group exceed a contracts limit in a rolling 15-second window, Kalshi cancels every resting order in the group and blocks new ones until you reset it. It's the one layer that still works if your bot is wedged.&lt;/p&gt;

&lt;p&gt;The retrofit created a fresh group on every start. Three runs made three groups (&lt;code&gt;mock-44e01417&lt;/code&gt;, &lt;code&gt;mock-60c841c9&lt;/code&gt;, &lt;code&gt;mock-4c8c7392&lt;/code&gt;), and every run ended with &lt;code&gt;order group left in place&lt;/code&gt;. Cancel-all never triggered any of them, so nothing stopped new orders on Kalshi's side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; save the group id in &lt;code&gt;state.json&lt;/code&gt; and reuse it on restart, after a &lt;code&gt;GET&lt;/code&gt; to check it still exists. Cancel-all now also sends &lt;code&gt;PUT /portfolio/order_groups/{id}/trigger&lt;/code&gt;, so Kalshi rejects new orders in the group until a person resets it. A normal shutdown skips the trigger with &lt;code&gt;--no-trigger&lt;/code&gt;. And if the group is missing or triggered, the bot refuses to start. Deleting KILL and restarting after the drill above gave:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;order group mock-d5135215 is triggered. Reset it by hand, then restart.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the behaviour you want. A kill switch that a restart quietly undoes isn't one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 6: shutdown sent a market order and said "closed" when it wasn't
&lt;/h2&gt;

&lt;p&gt;This was the one that worried us most. Here's the retrofit's shutdown handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pm_slugs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;venues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pm_close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;risk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RiskError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  close blocked &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order group left in place: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;venues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ORDER_GROUP_ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;closed Polymarket US positions; bye&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On exit it tried Polymarket US close-position for every configured market. Close-position is a market order with no slippage limit unless you set one. After the first run's normal stop, the mock's request log (timestamps trimmed) shows both going out, including one for the Miami market the bot had never managed to buy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"venue": "polymarket_us", "mock": true, "method": "POST", "path": "/v1/order/close-position", "body": {"marketSlug": "aec-nba-orl-ny-2026-10-25", "manualOrderIndicator": "MANUAL_ORDER_INDICATOR_AUTOMATIC"}}
{"venue": "polymarket_us", "mock": true, "method": "POST", "path": "/v1/order/close-position", "body": {"marketSlug": "tc-temp-miahigh-2026-10-26-gte83lt84f", "manualOrderIndicator": "MANUAL_ORDER_INDICATOR_AUTOMATIC"}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the third run we touched KILL. &lt;code&gt;check_order&lt;/code&gt; blocked the closes, and the last line still claimed success (cycle lines trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KILL ACTIVE
  close blocked aec-nba-orl-ny-2026-10-25: KILL ACTIVE
  close blocked tc-temp-miahigh-2026-10-26-gte83lt84f: KILL ACTIVE
closed Polymarket US positions; bye
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Meanwhile the Kalshi orders were left resting. So a normal exit fired market orders, and an emergency exit printed a lie and left the orders up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; shutdown runs &lt;code&gt;cancel_all --no-trigger&lt;/code&gt;, prints whether anything is still resting, and exits. Positions stay open. The bot never sends a close-position or market order on its own, and it doesn't print "closed" unless something closed. Closing a position in a thin or fast market can cost more than the position, so that decision belongs to a person looking at the book. A normal stop in the rebuild now ends like this, with no close or market order in the request log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;shutting down: cancelling resting orders (positions are left open)
[mock] kalshi before: 3 resting ['c8b64f68902a', '506abc41c89e', '9082b77822cb']
[mock] kalshi after: 0 resting []
[mock] polymarket_us before: 1 open ['f16c723114a8']
[mock] polymarket_us canceledOrderIds: ['f16c723114a8']
[mock] polymarket_us after: 0 open []
ALL CLEAR
bye
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One trap for any guard: same path, two meanings
&lt;/h2&gt;

&lt;p&gt;Kalshi creates orders with &lt;code&gt;POST /portfolio/events/orders&lt;/code&gt; and cancels all of them with &lt;code&gt;DELETE&lt;/code&gt; on the same path. Any guard that matches on path alone, like a test that says "every request to the orders path must go through &lt;code&gt;check_order&lt;/code&gt;", either blocks your cancel-all or misses your creates. Match method and path together.&lt;/p&gt;

&lt;p&gt;Two more venue facts worth knowing before you build this. Kalshi's cancel-all docs note that orders placed in the minute after the request may also be cancelled. And on Polymarket US, pure cancels are never rejected by its 5-second latency stopgap, so cancel-all still works when new orders are being rejected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The follow-up prompt
&lt;/h2&gt;

&lt;p&gt;Built this before Oct 25? Paste this into the same project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fix the risk limits and kill switch in this bot. Read risk.py,
cancel_all.py and every order function first; tell me which of these
problems you find before changing anything.

1. Every create, amend, modify and close must call check_order(action,
   order, state). Amends and modifies count toward max_orders_per_minute.
   Kalshi amend is POST /portfolio/events/orders/{id}/amend (count is the
   new total); Polymarket US modify is a cancel-replace.
2. Open exposure = resting orders + open positions, read from the venue
   (or the mock) at startup and every loop. An in-memory counter resets
   on restart. Kalshi: GET /portfolio/orders?status=resting and
   GET /portfolio/positions. Polymarket US: GET /v1/orders/open and
   GET /v1/portfolio/positions.
3. Daily loss: PnL = realized + unrealized, positions marked at the best
   bid. Save PnL at the first loop of each local day in state.json; daily
   loss = that minus PnL now. Compute it every loop in the main loop, not
   in a fill handler. At the limit: create KILL, run cancel_all, log it.
4. The ast test must catch POSTs to any order path (create, amend,
   modify, batch, close). Kalshi create and cancel-all share
   /portfolio/events/orders, so match method AND path.
5. Kalshi order group: save its id in state.json and reuse it (GET it
   first). If it's missing, create one; if it's triggered, refuse to
   start until I reset it.
6. cancel_all.py: Kalshi list, DELETE /portfolio/events/orders, then
   PUT /portfolio/order_groups/{id}/trigger (skip with --no-trigger),
   list again. Polymarket US POST /v1/orders/open/cancel {"slugs": []},
   then GET /v1/orders/open. Print before/after; exit 1 if anything is
   still resting. With no keys, make the mock really cancel.
7. Shutdown: run cancel_all --no-trigger and exit. Remove any automatic
   close-position or market order. Don't print "closed" unless it is.

Add tests for each fix, run them, and show me a diff summary.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Starting fresh? The full prompt is on the recipe page. Commit your bot first so you can see exactly what changes, set limits you'd be fine losing entirely and then halve them, and drill each layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run the tests. Then delete &lt;code&gt;check_order&lt;/code&gt; from one amend function and run them again. The &lt;code&gt;ast&lt;/code&gt; test should fail; if it doesn't, it isn't checking amends.&lt;/li&gt;
&lt;li&gt;Stop the bot with orders resting and start it again. Exposure should start where it left off, not at $0.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;touch KILL&lt;/code&gt;, confirm no new orders, run &lt;code&gt;python cancel_all.py&lt;/code&gt; and look for "after: 0" on both venues.&lt;/li&gt;
&lt;li&gt;In a copy, set a $1 daily-loss limit, seed a losing position in the mock, and confirm the main loop writes KILL and cancels everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Time yourself. You want "something's wrong" to "everything cancelled" in under 30 seconds. Then write the stop procedure on a sticky note: touch KILL, run cancel_all.py, check the venue UI, revoke the API key if anything looks wrong. Give the bot's Kalshi key only the scopes it needs (read and trade, not transfers).&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is and isn't
&lt;/h2&gt;

&lt;p&gt;The page's verified line, word for word: &lt;em&gt;Verified working on live data in mock mode, Oct 25, 2026. Ran against a local mock exchange on Oct 25 with live public Kalshi demo and Polymarket US prices, on three Kalshi and two Polymarket US markets: a restart, an amend, a KILL plus cancel-all drill, a daily-loss drill and a normal shutdown. We had no Kalshi demo or Polymarket US keys, so no signed request was sent; the real cancel-all, order-group and position calls are written from the docs and not yet run.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So: the logic was drilled, the venue calls were not. If you have Kalshi demo keys, run the same drills on the demo exchange, including a deliberately low &lt;code&gt;order_group_contracts_limit&lt;/code&gt; and a manual trigger, and check each call does what the docs say before trusting it. Polymarket US has no public demo exchange, so drill the local layers against a paper ledger and test cancel-all live only with a single tiny resting limit order far from the market. The bot in these runs is a placeholder quoter, not a strategy.&lt;/p&gt;

&lt;p&gt;The full recipe, with the complete prompt, step-by-step setup, the safety checklist and links to every Kalshi and Polymarket US doc we checked, is at &lt;strong&gt;&lt;a href="https://botcookery.com/recipes/trading-bot-kill-switch-position-limits/" rel="noopener noreferrer"&gt;botcookery.com/recipes/trading-bot-kill-switch-position-limits&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>tutorial</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Hyperliquid API in Python: our $25 cap refused 892 of 892 $25 orders</title>
      <dc:creator>BotCookery</dc:creator>
      <pubDate>Tue, 03 Nov 2026 14:53:09 +0000</pubDate>
      <link>https://dev.to/botcookery/hyperliquid-api-in-python-our-25-cap-refused-892-of-892-25-orders-1bk</link>
      <guid>https://dev.to/botcookery/hyperliquid-api-in-python-our-25-cap-refused-892-of-892-25-orders-1bk</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botcookery.com/recipes/hyperliquid-api-starter-testnet/" rel="noopener noreferrer"&gt;BotCookery&lt;/a&gt;, 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.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Most of it worked. Reads, asset lookups, price rounding, the &lt;code&gt;--mainnet&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Then we asked for $25, and the bot refused. Every time, on both networks: 424 of 424 dry-run $25 orders on testnet and 468 of 468 on mainnet. The cap meant to keep orders small was the thing blocking them. That was one of four problems the rebuild turned up; here's each one, what we saw in the logs, and how the prompt changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hyperliquid in one paragraph
&lt;/h2&gt;

&lt;p&gt;Everything is a JSON POST. Reads go to &lt;code&gt;/info&lt;/code&gt; with a &lt;code&gt;type&lt;/code&gt; (&lt;code&gt;meta&lt;/code&gt;, &lt;code&gt;allMids&lt;/code&gt;, &lt;code&gt;l2Book&lt;/code&gt;, &lt;code&gt;candleSnapshot&lt;/code&gt;, &lt;code&gt;clearinghouseState&lt;/code&gt;, &lt;code&gt;openOrders&lt;/code&gt;) and need no signature. Orders go to &lt;code&gt;/exchange&lt;/code&gt; and are signed, which is where the official &lt;code&gt;hyperliquid-python-sdk&lt;/code&gt; earns its keep. Testnet lives at &lt;code&gt;api.hyperliquid-testnet.xyz&lt;/code&gt;, mainnet at &lt;code&gt;api.hyperliquid.xyz&lt;/code&gt;, and they are different markets with different prices. The first lines of each of our cycles were testnet mids, then mainnet mids:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BTC        80973.5 ETH        2352.75 SOL        101.445
BTC        80646.5 ETH        2354.95 SOL        101.925
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few rules the prompt spells out, because an AI tool will otherwise guess:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Asset IDs differ between networks.&lt;/strong&gt; Look up the index and &lt;code&gt;szDecimals&lt;/code&gt; from &lt;code&gt;meta()&lt;/code&gt; on the same network every time. Never hardcode them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prices:&lt;/strong&gt; at most 5 significant figures and at most &lt;code&gt;6 - szDecimals&lt;/code&gt; decimals for perps. Integer prices are always valid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;$10 minimum&lt;/strong&gt; notional per order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent wallets have no balance.&lt;/strong&gt; You approve an agent key to sign for your main account, but balance and open-order lookups must use the main address.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. A $25 order could never pass
&lt;/h2&gt;

&lt;p&gt;The starter places a post-only (&lt;code&gt;Alo&lt;/code&gt;) limit 10% away from the mid, so the order rests instead of filling. Size is &lt;code&gt;usd / limit&lt;/code&gt;, rounded &lt;em&gt;up&lt;/em&gt; to &lt;code&gt;szDecimals&lt;/code&gt;. Rounding up is deliberate: rounding down, or sizing on the mid, leaves a $10 buy under the $10 exchange minimum.&lt;/p&gt;

&lt;p&gt;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, &lt;code&gt;plan BTC buy 25&lt;/code&gt; printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;refusing: notional 25.51 &amp;gt; $25.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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".&lt;/p&gt;

&lt;p&gt;The fix is to check two different things. The &lt;em&gt;requested&lt;/em&gt; amount must be between $10 and $25. The order's &lt;em&gt;own&lt;/em&gt; notional must be at least $10 and no more than the request plus one size step (&lt;code&gt;10^-szDecimals x limit&lt;/code&gt;). From the rebuilt version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 cli.py plan BTC buy 25
  "sz": 0.00035,
  "limit_px": 72876.0,
  "notional": 25.5066,
checks: pass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;0.00035 x 72,876 = 25.5066. Over $25 by one step's worth of rounding, which is exactly what the cap now allows.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Delisted coins looked tradable
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;allMids&lt;/code&gt; still quotes delisted perps. The original build treated "has a mid" as "trades", so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;python3 cli.py plan MATIC buy 10
...
checks: pass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MATIC is one of 54 delisted perps on testnet. The &lt;code&gt;meta&lt;/code&gt; response marks them with &lt;code&gt;"isDelisted": true&lt;/code&gt;, and the asset lookup now raises on that flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 cli.py plan MATIC buy 10
error: MATIC is delisted on testnet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;testnet 212 perps: {'pass_10': 316, 'pass_25': 316, 'refused_delisted': 216} []
mainnet 234 perps: {'pass_10': 356, 'pass_25': 356, 'refused_delisted': 224} []
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. The dead-man switch would have blocked every order on a new account
&lt;/h2&gt;

&lt;p&gt;The prompt has the bot call &lt;code&gt;scheduleCancel&lt;/code&gt; 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 &lt;code&gt;scheduleCancel&lt;/code&gt; didn't return ok, send no order.&lt;/p&gt;

&lt;p&gt;The problem is that Hyperliquid only allows &lt;code&gt;scheduleCancel&lt;/code&gt; above a trading-volume threshold. A &lt;a href="https://blog.mathquant.com/2025/01/16/quantitative-practice-of-dex-exchanges-2-hyperliquid-user-guide.html" rel="noopener noreferrer"&gt;published reply&lt;/a&gt; shows the error as &lt;code&gt;Cannot set scheduled cancel time until enough volume traded. Required: $1000000&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;orders.jsonl&lt;/code&gt; log from the run shows that refusal on every attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"schedule_cancel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"req"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1792953489708&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"err"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"response"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"User or API Wallet 0x46b7… does not exist."&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the volume error itself is sourced, not observed, and the code path for it is tested offline only.&lt;/p&gt;

&lt;p&gt;The new behavior keeps the safety without the dead end:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On that exact volume error, print &lt;code&gt;dead-man switch unavailable for this account&lt;/code&gt; and continue.&lt;/li&gt;
&lt;li&gt;On any other non-ok reply, still refuse and send no order. That's what happened in our runs: &lt;code&gt;refusing: schedule_cancel failed, no order sent&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;place&lt;/code&gt; now takes &lt;code&gt;--hold SECONDS&lt;/code&gt; (10 to 300). If the order rests, the bot waits, then cancels it by oid in a &lt;code&gt;finally&lt;/code&gt; block, so Ctrl-C during the hold still cancels. It then checks &lt;code&gt;openOrders&lt;/code&gt; that the oid is gone and exits non-zero if it isn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;One more check from the same runs: &lt;code&gt;place&lt;/code&gt; and &lt;code&gt;cancel-all&lt;/code&gt; don't accept &lt;code&gt;--mainnet&lt;/code&gt; at all. Passing it gets &lt;code&gt;cli.py: error: unrecognized arguments: --mainnet&lt;/code&gt; before anything is signed.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The main key was left on disk
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The original &lt;code&gt;approve_agent.py&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Now it reads the main key once with &lt;code&gt;getpass.getpass()&lt;/code&gt;, never writes it anywhere, and saves only &lt;code&gt;HL_MODE=testnet&lt;/code&gt;, &lt;code&gt;HL_AGENT_PRIVATE_KEY&lt;/code&gt; and &lt;code&gt;HL_ACCOUNT_ADDRESS&lt;/code&gt; (the main address) to &lt;code&gt;~/.config/hl-bot/.env&lt;/code&gt; with &lt;code&gt;chmod 600&lt;/code&gt;. It also saves only the agent key the SDK returns from &lt;code&gt;approve_agent&lt;/code&gt;, and only if the result is ok. On our unfunded account it saved nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 approve_agent.py
approve failed, nothing saved: {'status': 'err', 'response': 'Must deposit before performing actions. User: 0xd40d…'}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Already built it? The follow-up prompt
&lt;/h2&gt;

&lt;p&gt;If you built the starter from the earlier prompt, paste this into the same project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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`.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;The full prompt is on the &lt;a href="https://botcookery.com/recipes/hyperliquid-api-starter-testnet/" rel="noopener noreferrer"&gt;recipe page&lt;/a&gt;. If you'd rather start from code, the rebuilt starter is on GitHub at &lt;a href="https://github.com/lazyclaw137-ship-it/hyperliquid-api-starter-testnet" rel="noopener noreferrer"&gt;lazyclaw137-ship-it/hyperliquid-api-starter-testnet&lt;/a&gt;. Reads need nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;hyperliquid-python-sdk requests python-dotenv pytest
python3 cli.py mids
python3 cli.py book BTC &lt;span class="nt"&gt;--mainnet&lt;/span&gt;
python3 cli.py plan BTC buy 25
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Signed calls need a testnet account and an approved agent key, and stay on testnet. The code checks for that twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verified working on live data in read-only mode, Oct 25, 2026.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;More recipes, including a &lt;a href="https://botcookery.com/recipes/hyperliquid-funding-rate-monitor/" rel="noopener noreferrer"&gt;Hyperliquid funding-rate monitor&lt;/a&gt;, are at &lt;a href="https://botcookery.com/" rel="noopener noreferrer"&gt;botcookery.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>tutorial</category>
      <category>blockchain</category>
    </item>
    <item>
      <title>We rebuilt our Kalshi AI trading bot from its own prompt twice: it bought a filled order again</title>
      <dc:creator>BotCookery</dc:creator>
      <pubDate>Mon, 02 Nov 2026 14:55:03 +0000</pubDate>
      <link>https://dev.to/botcookery/we-rebuilt-our-kalshi-ai-trading-bot-from-its-own-prompt-twice-it-bought-a-filled-order-again-15pp</link>
      <guid>https://dev.to/botcookery/we-rebuilt-our-kalshi-ai-trading-bot-from-its-own-prompt-twice-it-bought-a-filled-order-again-15pp</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botcookery.com/recipes/kalshi-ai-trading-bot/" rel="noopener noreferrer"&gt;BotCookery&lt;/a&gt;, free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every log line below is from real runs on Oct 24 and Oct 27, 2026, with live market data from Kalshi's demo exchange and a mock broker: no keys, and no order was sent anywhere. Nothing here is financial advice, and the strategy in this bot is a placeholder.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our Kalshi AI trading bot recipe is a prompt. You paste it into your AI coding tool in an empty folder and it builds a small Python bot that runs on Kalshi's demo environment by default: it watches a few markets, applies your rule, checks hard limits in code, and places 1-contract limit orders inside an order group, with a kill switch and an optional LLM research step that only ever sees public market text.&lt;/p&gt;

&lt;p&gt;The interesting part of a bot like this isn't the strategy. It's everything around the order: what happens on restart, after a fill, when a market closes, when the API is slow. So we tested exactly that. Twice, we pasted the published prompt into a clean folder, built it as written, and ran it against live demo markets with a mock broker. The first pass (Oct 24) found three bugs. The second pass (Oct 27) built the fixed Oct 24 prompt exactly as written and found five more. The prompt is now fixed for all eight.&lt;/p&gt;

&lt;p&gt;None of them was a crash. Each one was the bot quietly doing the wrong thing with orders, limits or retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Kalshi side in one paragraph
&lt;/h2&gt;

&lt;p&gt;Orders go to &lt;code&gt;POST /portfolio/events/orders&lt;/code&gt; (Create Order V2; Kalshi says the legacy &lt;code&gt;/portfolio/orders&lt;/code&gt; will be deprecated no earlier than May 6, 2026). The body has &lt;code&gt;ticker&lt;/code&gt;, &lt;code&gt;side&lt;/code&gt; (&lt;code&gt;bid&lt;/code&gt; or &lt;code&gt;ask&lt;/code&gt;), &lt;code&gt;count&lt;/code&gt; as a string, &lt;code&gt;price&lt;/code&gt; as a fixed-point dollar string like &lt;code&gt;"0.5600"&lt;/code&gt;, &lt;code&gt;time_in_force&lt;/code&gt;, &lt;code&gt;self_trade_prevention_type&lt;/code&gt;, and optional &lt;code&gt;client_order_id&lt;/code&gt;, &lt;code&gt;post_only&lt;/code&gt;, &lt;code&gt;reduce_only&lt;/code&gt; and &lt;code&gt;cancel_order_on_pause&lt;/code&gt;. Order groups (&lt;code&gt;POST /portfolio/order_groups/create&lt;/code&gt;) auto-cancel every resting order in the group if fills exceed a contracts limit in a rolling 15-second window. &lt;code&gt;DELETE /portfolio/events/orders&lt;/code&gt; cancels everything. Demo lives at &lt;code&gt;https://external-api.demo.kalshi.co/trade-api/v2&lt;/code&gt;, with separate keys. Kalshi's order book lists bids only, so the implied YES ask is 1.00 minus the best NO bid; no NO bids means no ask, and the bot doesn't trade.&lt;/p&gt;

&lt;p&gt;Here's what the mock broker logs when the rule fires (Oct 27 clean rebuild, trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"event": "mock request (not sent)", "method": "POST", "path": "/portfolio/events/orders", "body": {"ticker": "KXNHLGOAL-26OCT27CBJPHI-PHIABUMP20-1", "side": "bid", "count": "1.00", "price": "0.1800", "time_in_force": "good_till_canceled", "self_trade_prevention_type": "taker_at_cross", "client_order_id": "8d438211-...", "order_group_id": "mock-group-6d89936d", "cancel_order_on_pause": true}}
{"ticker": "KXNHLGOAL-26OCT27CBJPHI-PHIABUMP20-1", "seen": {"yes_bid": 0.15, "yes_ask": 0.18, "status": "active"}, "rule": "fair 0.25 - ask 0.18 - fee 0.0103 = 0.0597 &amp;gt; margin 0.03: bid 1 @ 0.1800", "risk": "ok", "event": "order sent (mock)"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fair price of 0.25 is a placeholder in the config, not a view on the market.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: a filled order got bought again
&lt;/h2&gt;

&lt;p&gt;After the first pass, the prompt said: treat any ticker with a resting order as already ordered. That fixed restarts. It didn't fix fills. A filled order isn't resting any more, so on the next loop the ticker looks untouched and the bot bids again.&lt;/p&gt;

&lt;p&gt;We tested it by removing two orders from the mock's saved state, which is what a fill looks like from the bot's side. The very next loop sent new orders on both tickers. With a real account, that's a bot that buys again every time one of its orders fills.&lt;/p&gt;

&lt;p&gt;The fix: save the tickers ordered today in &lt;code&gt;state.json&lt;/code&gt;, and also skip any ticker where &lt;code&gt;GET /portfolio/positions&lt;/code&gt; shows a position. In the clean rebuild, we marked the one resting order as filled at 14:21:10 UTC. The log for that ticker changed from one reason to another and stayed there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"ts": "2026-10-27T14:21:09+00:00", "ticker": "KXNHLGOAL-26OCT27CBJPHI-PHIABUMP20-1", "event": "no order", "rule": "already have a resting order here"}
{"ts": "2026-10-27T14:21:21+00:00", "ticker": "KXNHLGOAL-26OCT27CBJPHI-PHIABUMP20-1", "event": "no order", "rule": "already ordered today"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next 41 loops on that ticker said "already ordered today" and sent nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: the orders-per-minute cap forgot itself on restart
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;max_orders_per_minute&lt;/code&gt; worked, as long as the process stayed up. The count lived in memory. In our drill the cap blocked an order at 13:11:54 UTC. We restarted the bot, and the same order went out nine seconds later.&lt;/p&gt;

&lt;p&gt;Any limit that lives only in memory is a limit that a crash loop, a supervisor restart or an impatient Ctrl-C resets. Order timestamps are now saved in &lt;code&gt;state.json&lt;/code&gt;. So is the 60-second wait after a cancel-all, which matters for a specific Kalshi reason: the docs for &lt;code&gt;DELETE /portfolio/events/orders&lt;/code&gt; say orders placed in the minute after the request may also be cancelled. If you clear the KILL file while the bot is stopped, the restart still waits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: a typo'd ticker was retried forever
&lt;/h2&gt;

&lt;p&gt;We put one deliberately wrong ticker in the config. The first build logged an identical data error for it on every single loop, 126 times in the run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"ts": "2026-10-27T13:08:05+00:00", "ticker": "KXBOTCOOKERY-NOTREAL", "event": "data error", "error": "HTTP 404: {\"error\":{\"code\":\"not_found\",\"message\":\"not found\"}}"}
{"ts": "2026-10-27T13:08:17+00:00", "ticker": "KXBOTCOOKERY-NOTREAL", "event": "data error", "error": "HTTP 404: {\"error\":{\"code\":\"not_found\",\"message\":\"not found\"}}"}
{"ts": "2026-10-27T13:08:29+00:00", "ticker": "KXBOTCOOKERY-NOTREAL", "event": "data error", "error": "HTTP 404: {\"error\":{\"code\":\"not_found\",\"message\":\"not found\"}}"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one is easy to hit for real: demo and production list different markets, so a ticker copied from kalshi.com usually 404s on demo. A 404 is now logged once and the ticker is skipped for the rest of the run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"ts": "2026-10-27T14:20:58+00:00", "ticker": "KXBOTCOOKERY-NOTREAL", "event": "ticker not found (404): skipping it from now on"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the clean rebuild that line appeared once per process, five processes in all, instead of 126 times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: a closed market's order stayed in exposure forever
&lt;/h2&gt;

&lt;p&gt;One demo market finalized at 13:20 UTC, and the mock broker still listed our order on it as resting. So it counted against the $5 exposure cap for the rest of the run. On the real exchange, resting orders are cancelled when a market closes; the mock didn't know that, which means every exposure test after the first close was testing a fiction.&lt;/p&gt;

&lt;p&gt;The mock now drops resting orders when a market's status is no longer &lt;code&gt;active&lt;/code&gt;, and logs it. In the longer live run we watched short-lived demo markets go &lt;code&gt;initialized&lt;/code&gt; to &lt;code&gt;active&lt;/code&gt; to &lt;code&gt;closed&lt;/code&gt; to &lt;code&gt;determined&lt;/code&gt;, and the new code caught it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"ts": "2026-10-27T14:00:13+00:00", "ticker": "KXQUICKSETTLE-27OCT26H1000-3", "event": "market closed: 1 resting order(s) now gone"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point generalizes: if your mock is more forgiving than the exchange, your risk checks pass for the wrong reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 5: retries ignored the server and the clock
&lt;/h2&gt;

&lt;p&gt;For this one we put a local fault-injecting relay between the bot and Kalshi's demo host, and made it misbehave on purpose. A 429 carrying &lt;code&gt;Retry-After: 5&lt;/code&gt; was retried after a fixed 0.5 to 8 seconds, ignoring the header. With the server returning 429 or 503, one loop over six tickers took 48 seconds. With a server that answered slowly, it took 60.&lt;/p&gt;

&lt;p&gt;The client now retries only GETs (never an order), honours &lt;code&gt;Retry-After&lt;/code&gt; up to 30 seconds, uses a 5-second connect and 10-second read timeout, and ends the loop after two tickers in a row fail on the network. One loop each with the final code: 429 with &lt;code&gt;Retry-After: 5&lt;/code&gt; took 40 seconds, 503 took 16, a dropped connection 16.&lt;/p&gt;

&lt;p&gt;The slow-server drill also caught a bug in our own fix. The 30-second limit covered one attempt, not the retries, so one ticker could spend 58 seconds retrying. A 25-second total budget per call brought it to 32 seconds per ticker. A loop that gives up after two tickers still takes about 63 seconds, which is no faster than before for a slow server. It just stops hammering it.&lt;/p&gt;

&lt;p&gt;Here's the clean rebuild against the relay returning 429 with &lt;code&gt;Retry-After: 5&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"ts": "2026-10-27T14:26:04+00:00", "event": "reusing order group", "order_group_id": "mock-group-6d89936d"}
{"ts": "2026-10-27T14:26:24+00:00", "ticker": "KXWTAMATCH-26OCT28DARTJE-TJE", "event": "data error", "error": "HTTP 429: {\"error\":{\"code\":\"too_many_requests\",\"message\":\"too many requests\"}}"}
{"ts": "2026-10-27T14:26:44+00:00", "ticker": "KXWTAMATCH-26OCT28DARTJE-DAR", "event": "data error", "error": "HTTP 429: {\"error\":{\"code\":\"too_many_requests\",\"message\":\"too many requests\"}}"}
{"ts": "2026-10-27T14:26:44+00:00", "event": "2 tickers in a row failed on the network: ending this loop early"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five tries per ticker, 5 seconds apart, then the loop ended after two tickers: 41 seconds and 10 requests in all, every one a GET.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also new: the fee comes off the edge
&lt;/h2&gt;

&lt;p&gt;The bot bids at the implied ask, so it crosses the book and pays Kalshi's taker fee: 0.07 x price x (1 - price) per contract, up to 1.75 cents. The prompt now subtracts that before comparing your edge to the margin. That's the &lt;code&gt;fee 0.0103&lt;/code&gt; in the log above (0.07 x 0.18 x 0.82). It comes from Kalshi's fee schedule, not from the run, but with a 3-cent margin in the config it can decide whether the rule fires at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first pass, Oct 24: three bugs that came first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Restarting stacked a second order.&lt;/strong&gt; The original prompt said "at most one order per ticker per run". Our build kept that in memory, so a restart sent a second resting bid on the same market and created a second order group. That's where the "resting order = already ordered" rule and the saved order group id came from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily loss came from the wrong place.&lt;/strong&gt; The old prompt computed it from &lt;code&gt;GET /portfolio/positions&lt;/code&gt; as realized P&amp;amp;L minus fees. That endpoint returns unsettled positions by default, and its P&amp;amp;L is per market, not per day. Fed to the risk module as written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;old unsettled loss counts as today: 2.15
after settlement it vanishes     : 0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A loss from last week would block today's trading, and a losing market stops counting the moment it settles. Daily loss now comes from &lt;code&gt;GET /portfolio/balance&lt;/code&gt;: equity (balance plus portfolio value) at the first loop of the UTC day, minus equity now, saved in &lt;code&gt;state.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resting orders didn't count as exposure.&lt;/strong&gt; Only positions counted, so unfilled bids were invisible to the cap, and the mock returned no positions at all, so the cap never ran. Exposure now adds the cost of resting orders.&lt;/p&gt;

&lt;p&gt;A few things we noticed along the way: of the 8,000 open, non-combo demo markets we listed on Oct 24, only 6 had both a YES bid and a YES ask. You filter with &lt;code&gt;?status=open&lt;/code&gt;, but each market comes back as &lt;code&gt;active&lt;/code&gt;. And from our server, &lt;code&gt;external-api.kalshi.com&lt;/code&gt; returned 403 while &lt;code&gt;api.elections.kalshi.com&lt;/code&gt; answered, which is why the production base URL is overridable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebuilt from scratch: what held
&lt;/h2&gt;

&lt;p&gt;After the fixes, we built the bot again in an empty folder from the published prompts only. 27 tests passed. Then 44 loops over about 11 minutes on 8 live demo tickers, mock broker. The filled order wasn't bought again. A restart reused the saved order group and sent 0 orders. A second copy refused to start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;another copy of the bot is already running: refusing to start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a market closed, the mock dropped its resting order and logged it. And one gap in the prompt itself turned up: a &lt;code&gt;Retry-After&lt;/code&gt; of 30 seconds can never fit a 25-second budget. The rebuild gave up instead of waiting, and the prompt now says a wait that would pass the budget ends the retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The follow-up prompt
&lt;/h2&gt;

&lt;p&gt;Built the bot before Oct 27? Paste this into the same project. (If you built it before Oct 24, the recipe page has a second follow-up prompt to paste first.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Update my Kalshi bot:
1. A filled order is no longer resting, so save the tickers I ordered today
   in state.json and skip them. Also skip any ticker where GET
   /portfolio/positions shows a position.
2. Save order timestamps in state.json so max_orders_per_minute survives a
   restart. Save the 60 s post-cancel-all wait there too, so clearing KILL
   while the bot is stopped still waits.
3. If a ticker returns 404, log it once and skip it for the rest of the run.
4. Mock broker: when a market's status is no longer active, drop its resting
   orders from logs/mock_state.json and log it, so exposure is released.
5. Subtract Kalshi's taker fee, 0.07 * ask * (1 - ask), from my edge before
   comparing it to the margin.
6. HTTP client: retry GETs only, never POSTs. Honor Retry-After (cap 30 s),
   timeout (5 s connect, 10 s read), a 25 s total retry budget per call (a wait that
   would pass it ends the retries), and
   end the loop early if two tickers in a row fail on the network. Page
   through GET /portfolio/orders?status=resting with the cursor.
7. A second copy of the bot must refuse to start (file lock).
Add tests for each, run them, and show me the diff before changing anything else.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Starting fresh? The full prompt is on the recipe page. Do the &lt;a href="https://botcookery.com/recipes/kalshi-api-starter/" rel="noopener noreferrer"&gt;Kalshi API starter&lt;/a&gt; first for a signed client, pick 1-3 tickers from the demo market list (ones with both a bid and an ask), set tiny limits (1 contract per order, $5 exposure, $2 daily loss), and run it with no keys at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BROKER_MOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 python bot.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mock logs each request it would send and sends nothing. Its balance is a fixed $100 labelled mock, so the daily-loss limit only really runs with demo keys. Then drill the safety net: &lt;code&gt;touch KILL&lt;/code&gt; cancels everything once and stops ordering, removing it waits 60 seconds, and deleting an order from &lt;code&gt;logs/mock_state.json&lt;/code&gt; simulates a fill that must not be bought again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is and isn't
&lt;/h2&gt;

&lt;p&gt;The page's verified line, word for word: &lt;em&gt;Verified working on live data in mock mode, Oct 27, 2026. Clean-folder rebuild from the published prompts only, then live demo market data (44 loops over about 11 minutes), mock broker: no demo keys and no orders sent, so the daily-loss limit was not exercised. Fills and outages were simulated (edited mock state, a local fault-injecting relay). The same assistant that wrote the earlier versions did the rebuild, and the Oct 24 and Oct 27 follow-up prompts were checked against the new build rather than run as an upgrade of an old one.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Some changes are covered by unit tests only, not by the live run: the stop when the order group is rejected, &lt;code&gt;limits.json&lt;/code&gt; failing closed if a value is missing or not positive, and paginated &lt;code&gt;resting_orders&lt;/code&gt;. These runs prove the plumbing, not a strategy. Demo prices barely moved, the fair prices are placeholders, and Kalshi notes that demo prices and behavior may differ from real markets. If you turn on the research step, treat what the LLM writes as advisory: LLM probability estimates are often confident and badly calibrated. And Kalshi's Developer Agreement allows API use for your own trading, not for running a bot for others.&lt;/p&gt;

&lt;p&gt;The full recipe, with the complete prompt, step-by-step setup, the safety checklist and links to every Kalshi doc we checked, is at &lt;strong&gt;&lt;a href="https://botcookery.com/recipes/kalshi-ai-trading-bot/" rel="noopener noreferrer"&gt;botcookery.com/recipes/kalshi-ai-trading-bot&lt;/a&gt;&lt;/strong&gt;. The code from the verified run is on GitHub at &lt;a href="https://github.com/lazyclaw137-ship-it/kalshi-ai-trading-bot" rel="noopener noreferrer"&gt;lazyclaw137-ship-it/kalshi-ai-trading-bot&lt;/a&gt; (MIT, no keys), and the three-layer kill switch is written up on its own in our &lt;a href="https://botcookery.com/recipes/trading-bot-kill-switch-position-limits/" rel="noopener noreferrer"&gt;kill switch and position limits&lt;/a&gt; recipe.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>tutorial</category>
      <category>ai</category>
    </item>
    <item>
      <title>Polymarket US API in Python: Cloudflare rate-limited us long before 25 req/s</title>
      <dc:creator>BotCookery</dc:creator>
      <pubDate>Fri, 30 Oct 2026 14:09:30 +0000</pubDate>
      <link>https://dev.to/botcookery/polymarket-us-api-in-python-cloudflare-rate-limited-us-long-before-25-reqs-2866</link>
      <guid>https://dev.to/botcookery/polymarket-us-api-in-python-cloudflare-rate-limited-us-long-before-25-reqs-2866</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botcookery.com/recipes/polymarket-api-starter-kit/" rel="noopener noreferrer"&gt;BotCookery&lt;/a&gt;, free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every output below is from real runs against the public Polymarket US gateway, on Oct 25, 2026 (the rebuild) and Oct 30, 2026 (a re-run of the rebuilt code). No API key was used, the code can't place an order, and nothing here is financial advice.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The old version of our Polymarket US prompt told the AI to "stay well under 25 requests/second" and to back off 1 s, 2 s, then 4 s on HTTP 429. That sounds careful. On the live gateway it fell apart in the first loop: 28 of 138 CLI calls crashed with &lt;code&gt;HTTPError: 429 Client Error: Too Many Requests&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This morning we measured the limit directly, at a polite one request per second on a single order book:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;13:59:28 call  1 status 200 ct=application/json retry-after=None body='{"marketData":{"marketSlug":"paccc-usho-'
...
13:59:32 call  6 status 200 ct=application/json retry-after=None body='{"marketData":{"marketSlug":"paccc-usho-'
13:59:33 call  7 status 429 ct=text/html; charset=UTF-8 retry-after=10 body='&amp;lt;!doctype html&amp;gt;\n&amp;lt;!--[if lt IE 7]&amp;gt; &amp;lt;html '
13:59:34 call  8 status 429 ct=text/html; charset=UTF-8 retry-after=9 body='&amp;lt;!doctype html&amp;gt;\n&amp;lt;!--[if lt IE 7]&amp;gt; &amp;lt;html '
...
13:59:43 call 17 status 429 ct=text/html; charset=UTF-8 retry-after=0 body='&amp;lt;!doctype html&amp;gt;\n&amp;lt;!--[if lt IE 7]&amp;gt; &amp;lt;html '
13:59:44 call 18 status 200 ct=application/json retry-after=None body='{"marketData":{"marketSlug":"paccc-usho-'
...
13:59:48 call 22 status 200 ct=application/json retry-after=None body='{"marketData":{"marketSlug":"paccc-usho-'
13:59:50 call 23 status 429 ct=text/html; charset=UTF-8 retry-after=10 body='&amp;lt;!doctype html&amp;gt;\n&amp;lt;!--[if lt IE 7]&amp;gt; &amp;lt;html '
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six calls went through, then eleven 429s while &lt;code&gt;Retry-After&lt;/code&gt; counted down from 10 to 0, then five more 200s, then another 10-second block. The pattern repeated a third time. Of 40 calls in about 40 seconds, 16 succeeded.&lt;/p&gt;

&lt;p&gt;This post is about that, and the three other things a live run turned up that the docs and unit tests didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the starter is
&lt;/h2&gt;

&lt;p&gt;The Polymarket API starter kit is a prompt, not a repo. You paste it into an AI coding tool in an empty folder, and it builds a small read-only Python toolkit for &lt;strong&gt;Polymarket US&lt;/strong&gt;, the CFTC-regulated exchange at &lt;code&gt;gateway.polymarket.us&lt;/code&gt;. It's a different API from the international CLOB.&lt;/p&gt;

&lt;p&gt;The CLI has &lt;code&gt;markets&lt;/code&gt;, &lt;code&gt;search&lt;/code&gt;, &lt;code&gt;bbo&lt;/code&gt;, &lt;code&gt;book&lt;/code&gt;, &lt;code&gt;watch&lt;/code&gt;, and a signed &lt;code&gt;balance&lt;/code&gt; read for when you have a key. Public market data needs no key. Account reads are signed with Ed25519. There is no order code at all.&lt;/p&gt;

&lt;p&gt;On Oct 25 we pasted the old prompt into a clean folder, let it build, and pointed a harness at it. Then we fixed the prompt, rebuilt from the new version, and ran that too. Today we re-ran the rebuilt code, unchanged, to check it still holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gateway in one paragraph
&lt;/h2&gt;

&lt;p&gt;Public reads are plain GETs on &lt;code&gt;https://gateway.polymarket.us&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/v1/markets?active=true&amp;amp;closed=false&amp;amp;limit=N&lt;/code&gt; lists markets.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/v1/markets/{slug}/bbo&lt;/code&gt; gives the top of book under &lt;code&gt;marketData&lt;/code&gt;: &lt;code&gt;bestBid&lt;/code&gt;, &lt;code&gt;bestAsk&lt;/code&gt;, &lt;code&gt;currentPx&lt;/code&gt;, &lt;code&gt;lastTradePx&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;, plus depth and open-interest fields.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/v1/markets/{slug}/book&lt;/code&gt; gives the full book. Bids are &lt;code&gt;marketData.bids&lt;/code&gt;, asks are &lt;code&gt;marketData.offers&lt;/code&gt; (there is no &lt;code&gt;asks&lt;/code&gt; key), and each level is &lt;code&gt;{px: {value}, qty}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/v1/search?query=...&lt;/code&gt; returns &lt;code&gt;{"events": [...]}&lt;/code&gt;, each event holding its markets.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every market is a YES contract on one side, priced 0.00 to 1.00. To be short you sell YES, so the NO price you can get is 1 minus the best bid.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. "Stay under 25 req/s" isn't the limit you get
&lt;/h2&gt;

&lt;p&gt;The old build did what the old prompt asked. It throttled itself to 10 requests a second (&lt;code&gt;_MIN_INTERVAL = 1 / 10  # stay well under 25 req/s&lt;/code&gt;) and retried a 429 after 1, 2 and 4 seconds before calling &lt;code&gt;raise_for_status()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Our harness took the top markets plus the first two events from four searches a beginner might try ("temperature", "bitcoin", "election", "nfl"): 69 markets. For each one it ran the CLI's &lt;code&gt;bbo&lt;/code&gt; and &lt;code&gt;book&lt;/code&gt;, then checked the market's state. Loop 1 finished at 17:36:50 UTC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;slugs 69
[17:36:50 UTC] loop 1: {'bbo_ok': 60, 'book_ok': 50, 'book_crash': 19, 'check_err': 23, 'bbo_ask_ne_book': 2, 'bbo_crash': 9, 'no_bids': 27, 'no_offers': 20, 'spread&amp;gt;=0.10': 4} states={'MARKET_STATE_OPEN': 26, 'MARKET_STATE_EXPIRED': 20}
    book tec-mlb-champ-2026-09-27-tb: HTTPError: 429 Client Error: Too Many Requests for url: https://gateway.polymarket.us/v1/markets/tec-mlb-champ-2026-09-27-tb/book
    bbo tec-mls-winner-2026-11-07-sea: HTTPError: 429 Client Error: Too Many Requests for url: https://gateway.polymarket.us/v1/markets/tec-mls-winner-2026-11-07-sea/bbo
...
exit 124
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 9 &lt;code&gt;bbo&lt;/code&gt; and 19 &lt;code&gt;book&lt;/code&gt; crashes, and 23 state checks that errored too. The harness hit its time limit (&lt;code&gt;exit 124&lt;/code&gt;) before loop 2 finished.&lt;/p&gt;

&lt;p&gt;The probe at the top explains why. The gateway sits behind Cloudflare, and the 429 isn't a JSON API error. It's an HTML page with a &lt;code&gt;Retry-After&lt;/code&gt; header that starts at 10 and counts down. The old backoff waited 1 + 2 + 4 = 7 seconds in total, then gave up while the block still had seconds to run. And any code that calls &lt;code&gt;.json()&lt;/code&gt; on that response gets a &lt;code&gt;JSONDecodeError&lt;/code&gt; from an HTML page, which is a confusing way to learn you were rate limited.&lt;/p&gt;

&lt;p&gt;Our box is a cloud server, and Cloudflare may treat home connections more gently. Still, a script polling the public gateway should plan for roughly five calls, then a 10-second wait.&lt;/p&gt;

&lt;p&gt;What the prompt says now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One shared throttle for every call, one request every 2 seconds by default (&lt;code&gt;PM_MIN_INTERVAL&lt;/code&gt; overrides it).&lt;/li&gt;
&lt;li&gt;On 429, sleep &lt;code&gt;Retry-After&lt;/code&gt; + 1 (10 if the header is missing) and retry up to 4 times, then raise one &lt;code&gt;PMError&lt;/code&gt; saying it was rate limited. Retry 5xx and timeouts the same way.&lt;/li&gt;
&lt;li&gt;Never call &lt;code&gt;.json()&lt;/code&gt; on a non-200. Put the status and the first 120 characters of the body in the error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even at one call every 2 seconds, Cloudflare still pushed back once in each watch. On Oct 25:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[17:43:19 UTC] loop 1: 20 two-sided, 0 one-sided, 0 errors, 0 rate-limit waits (0 s)
[17:44:58 UTC] loop 2: 20 two-sided, 0 one-sided, 0 errors, 0 rate-limit waits (0 s)
[17:46:47 UTC] loop 3: 20 two-sided, 0 one-sided, 0 errors, 1 rate-limit waits (11 s)
...
[17:51:41 UTC] loop 6: 20 two-sided, 0 one-sided, 0 errors, 0 rate-limit waits (0 s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And today, with the same code, about 30 seconds after the probe finished:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[14:01:29 UTC] loop 1: 20 two-sided, 0 one-sided, 0 errors, 1 rate-limit waits (11 s)
[14:03:07 UTC] loop 2: 20 two-sided, 0 one-sided, 0 errors, 0 rate-limit waits (0 s)
[14:04:45 UTC] loop 3: 20 two-sided, 0 one-sided, 0 errors, 0 rate-limit waits (0 s)
[14:06:23 UTC] loop 4: 20 two-sided, 0 one-sided, 0 errors, 0 rate-limit waits (0 s)
[14:08:01 UTC] loop 5: 20 two-sided, 0 one-sided, 0 errors, 0 rate-limit waits (0 s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;100 quotes, one wait of 11 seconds, then it carried on. No crashes, no tracebacks. The summary line counts the waits on purpose, so you can see how close you're running to the limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A market list you can read
&lt;/h2&gt;

&lt;p&gt;The old &lt;code&gt;markets&lt;/code&gt; command printed the slug and the market's &lt;code&gt;question&lt;/code&gt;. On Polymarket US the question is often the &lt;em&gt;event&lt;/em&gt;, shared by every side of it, so rows looked alike. The rebuilt version asks the gateway to sort (&lt;code&gt;orderBy=volume&amp;amp;orderDirection=desc&lt;/code&gt;; the docs don't list sort values, but this pair works), drops anything whose &lt;code&gt;status&lt;/code&gt; isn't open, and prints each market's &lt;code&gt;title&lt;/code&gt;, which is the side you're actually pricing. Today's top of the list, from &lt;code&gt;watch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  two-sided bid 0.929 (92.9%)  ask 0.930 (93.0%)  Democratic Party         paccc-usho-midterms-2026-11-03-dem
  two-sided bid 0.300 (30.0%)  ask 0.310 (31.0%)  Ken Paxton (R)           ewc-usse-tx-2026-11-03-rep
  two-sided bid 0.696 (69.6%)  ask 0.697 (69.7%)  Democratic Party         paccc-usse-midterms-2026-11-03-dem
  two-sided bid 0.314 (31.4%)  ask 0.315 (31.5%)  Republican Party         paccc-usse-midterms-2026-11-03-rep
  two-sided bid 0.690 (69.0%)  ask 0.700 (70.0%)  James Talarico (D)       ewc-usse-tx-2026-11-03-dem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even the title isn't always enough. "Democratic Party" appears twice in the top three: one is the House (&lt;code&gt;usho&lt;/code&gt;), the other the Senate (&lt;code&gt;usse&lt;/code&gt;). Always print the slug next to the title. Game moneylines have no &lt;code&gt;title&lt;/code&gt; at all, so the toolkit prints their outcomes instead (today: "Giants / Commanders"). One more small thing from today: one title came back with a tab inside it (&lt;code&gt;Peggy Flanagan&amp;lt;TAB&amp;gt;(D)&lt;/code&gt;), which knocked that row's column alignment out. Titles are data, not display strings.&lt;/p&gt;

&lt;p&gt;Four days before the US midterms, all 20 of today's top markets by volume were Nov 3 election markets. On Oct 25 the top 20 also included World Series markets. Over today's five loops, the only quote that moved was the Senate "Republican Party" market, from 0.314/0.315 to 0.311/0.312. A quiet watch is normal.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Search returns finished events
&lt;/h2&gt;

&lt;p&gt;Part of the old harness's market list came from search. Of the 46 markets whose state it could check, 20 were &lt;code&gt;MARKET_STATE_EXPIRED&lt;/code&gt;, and 27 had no bids at all. If your script searches and then reads a book, expect to land on dead markets.&lt;/p&gt;

&lt;p&gt;The rebuilt search hides markets that aren't open and says how many it hid. On Oct 25, &lt;code&gt;search nfl&lt;/code&gt; ended with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(29 resolved markets hidden; --all to show)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Today showed a different trap. Search matches slugs, and in college basketball &lt;code&gt;nfl&lt;/code&gt; is North Florida. The first five events for &lt;code&gt;search nfl&lt;/code&gt; were North Florida games:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;North Florida (W) vs. Little Rock (W)
   - aec-wcbb-nfl-arlr-2026-11-12                 Ospreys / Trojans
North Florida (W) vs. Florida State (W)
   - aec-wcbb-nfl-flst-2026-11-05                 Ospreys / Seminoles
...
WAS Commanders vs. NY Giants
   - aec-nfl-was-nyg-2026-11-12                   Giants / Commanders
Week 8 Top D/ST Fantasy Football
   - ftsc-wk8-2026-11-02-topdst-kc                KC Chiefs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The football game came sixth. Read the event title before you trust a search hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The rest: fees, NO prices, and errors
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;bbo&lt;/code&gt; shows the NO price and the taker fee, using the market's own &lt;code&gt;feeCoefficient&lt;/code&gt; and the fee page's formula, coefficient x contracts x p x (1 - p). Today on the top market:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;paccc-usho-midterms-2026-11-03-dem  state=MARKET_STATE_OPEN
  bid:  0.929 (92.9%)
  ask:  0.930 (93.0%)
  NO:   0.071 (7.1%)  (= 1 - best bid; to buy NO you sell YES)
  last trade: 0.929 (92.9%)
  taker fee, 100 @ ask: $0.45 (coef 0.0695)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check it: 0.0695 x 100 x 0.93 x 0.07 = 0.452. On Oct 25 the same market was 0.900/0.901 and the fee for 100 at the ask was $0.62. The fee peaks at 50% and shrinks toward the edges, so the same 100 contracts cost less in fees as the price nears 1.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;book&lt;/code&gt; sorts both sides itself before taking the best. Today, at depth 5:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  BIDS
    0.929 (92.9%)  x 758529
    0.928 (92.8%)  x 4621
    ...
  OFFERS
    0.930 (93.0%)  x 12105
    0.931 (93.1%)  x 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a bad slug is one line and exit code 1, not a traceback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: HTTP 404 from /v1/markets/no-such-market-xyz/book: '{"code":5,"message":"The server was unable to process your request.","details":[]}'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no key set, &lt;code&gt;balance&lt;/code&gt; prints &lt;code&gt;no key set (POLYMARKET_KEY_ID / POLYMARKET_SECRET_KEY)&lt;/code&gt; and exits cleanly. The rebuilt version has 9 mocked tests, including an HTML 429 with &lt;code&gt;Retry-After&lt;/code&gt;, giving up after retries, one-sided books, and search hiding resolved markets. Today: &lt;code&gt;9 passed in 0.10s&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Already built it? Paste this follow-up prompt
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Update my Polymarket US starter:
1. _get: one shared throttle, one call every 2 s (env PM_MIN_INTERVAL).
   On 429 (an HTML page from Cloudflare, not JSON) sleep Retry-After + 1
   (10 if missing), retry up to 4 times, then raise PMError. Retry 5xx and
   timeouts too. Never call .json() on a non-200.
2. list_markets: add orderBy=volume&amp;amp;orderDirection=desc and drop markets
   whose status isn't MARKET_STATUS_OPEN.
3. Print each market's title (the side) next to the slug; for moneylines
   with no title, print the outcomes. markets shows bid/ask from
   bestBidQuote/bestAskQuote.
4. search: limit=20, hide markets that aren't MARKET_STATUS_OPEN unless
   --all, and print how many were hidden.
5. Sort bids and offers yourself before taking the best; either side
   can be empty.
6. bbo: show NO = 1 - best bid and the taker fee for 100 contracts at the
   ask: feeCoefficient x 100 x p x (1 - p).
7. Add `watch [--n 10] [--loops 1] [--interval 60]` with one line per
   market and a "[HH:MM:SS UTC] loop N: ... errors, W rate-limit waits"
   summary, flush=True; one market's error never stops the loop.
8. The CLI catches PMError, prints one line and exits 1.
Add mocked tests for each change and run them. Still no order placement.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Starting fresh? The full prompt is on the recipe page. After it builds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install &lt;/span&gt;requests python-dotenv cryptography pytest
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; pytest &lt;span class="nt"&gt;-q&lt;/span&gt;
python3 cli.py markets &lt;span class="nt"&gt;--n&lt;/span&gt; 20
python3 cli.py bbo &amp;lt;slug-from-the-list&amp;gt;
python3 cli.py book &amp;lt;slug&amp;gt; &lt;span class="nt"&gt;--depth&lt;/span&gt; 5
python3 cli.py watch &lt;span class="nt"&gt;--n&lt;/span&gt; 20 &lt;span class="nt"&gt;--loops&lt;/span&gt; 5 &lt;span class="nt"&gt;--interval&lt;/span&gt; 60 | &lt;span class="nb"&gt;tee &lt;/span&gt;watch.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What this is and isn't
&lt;/h2&gt;

&lt;p&gt;The page's verified line, word for word: &lt;em&gt;Verified working on live data in read-only mode, Oct 25, 2026. Public market data only: a 10-minute, 6-loop watch of the top 20 markets plus markets, search, bbo and book. No API key was created, so balance and positions were never called; the request signing was checked offline against the official SDK.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The toolkit reads public prices and, if you give it a key, your own balance. It has no order code. It prints prices, depth and fees. It doesn't rank markets or suggest trades. If you take it further, keep keys in &lt;code&gt;.env&lt;/code&gt;, out of git, and check what the gateway's rate limit is doing from the machine you'll actually run on.&lt;/p&gt;

&lt;p&gt;The full recipe, with the complete prompt, setup steps, FAQ and links to every Polymarket US doc we checked, is at &lt;strong&gt;&lt;a href="https://botcookery.com/recipes/polymarket-api-starter-kit/" rel="noopener noreferrer"&gt;botcookery.com/recipes/polymarket-api-starter-kit&lt;/a&gt;&lt;/strong&gt;. To try strategies without real money, see our &lt;a href="https://botcookery.com/recipes/polymarket-bot-claude-code-paper-mode/" rel="noopener noreferrer"&gt;Polymarket paper-mode bot built with Claude Code&lt;/a&gt;. For the international order book, see the &lt;a href="https://botcookery.com/recipes/polymarket-clob-api-starter/" rel="noopener noreferrer"&gt;Polymarket CLOB API starter&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>tutorial</category>
      <category>fintech</category>
    </item>
    <item>
      <title>We rebuilt our Hyperliquid funding-rate monitor from its own prompt: the biggest rates were on perps nobody trades</title>
      <dc:creator>BotCookery</dc:creator>
      <pubDate>Thu, 29 Oct 2026 13:57:48 +0000</pubDate>
      <link>https://dev.to/botcookery/we-rebuilt-our-hyperliquid-funding-rate-monitor-from-its-own-prompt-the-biggest-rates-were-on-2314</link>
      <guid>https://dev.to/botcookery/we-rebuilt-our-hyperliquid-funding-rate-monitor-from-its-own-prompt-the-biggest-rates-were-on-2314</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botcookery.com/recipes/hyperliquid-funding-rate-monitor/" rel="noopener noreferrer"&gt;BotCookery&lt;/a&gt;, free 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.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our Hyperliquid funding-rate monitor is a prompt. Paste it into your AI coding tool in an empty folder and you get a small Python CLI: a snapshot of current and predicted funding for every perp, annualized and labelled, funding history for one coin, and a &lt;code&gt;watch&lt;/code&gt; loop that alerts to the console or a webhook when a rate crosses a threshold you set. It only calls the public &lt;code&gt;POST /info&lt;/code&gt; endpoint. No key, no wallet, no &lt;code&gt;/exchange&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On Oct 25 we pasted the published prompt into a clean folder, let it build, and ran every step 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 that only show up on live data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API in one paragraph
&lt;/h2&gt;

&lt;p&gt;Everything is &lt;code&gt;POST https://api.hyperliquid.xyz/info&lt;/code&gt; (testnet: &lt;code&gt;api.hyperliquid-testnet.xyz&lt;/code&gt;). &lt;code&gt;{"type":"metaAndAssetCtxs"}&lt;/code&gt; returns &lt;code&gt;[meta, assetCtxs]&lt;/code&gt;: zip &lt;code&gt;meta.universe[i]&lt;/code&gt; with &lt;code&gt;assetCtxs[i]&lt;/code&gt; and read &lt;code&gt;funding&lt;/code&gt;, the current &lt;strong&gt;hourly&lt;/strong&gt; rate. &lt;code&gt;{"type":"predictedFundings"}&lt;/code&gt; returns Hyperliquid's predicted rate next to Binance's and Bybit's, each with its own &lt;code&gt;fundingIntervalHours&lt;/code&gt;. &lt;code&gt;{"type":"fundingHistory","coin":"ETH","startTime":...}&lt;/code&gt; returns hourly prints, at most 500 rows per call. Hyperliquid pays funding every hour, at one-eighth of an 8-hour formula rate, and a quiet market sits at 0.00125% per hour. That's 11.0% a year if you multiply by 8,760, which is a comparison convention, not a forecast.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The top of the list was perps nobody trades
&lt;/h2&gt;

&lt;p&gt;The first build sorted every perp by absolute APR and alerted on anything over the threshold. Here's the first loop of its watch at 45%, Oct 25, 09:30 UTC (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loudest number in the whole market was a perp with a tiny book. Thin perps drift further from the oracle, 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, against $2.3B of volume on BTC. On that run, 111 of the 179 live perps had under $1M of 24-hour volume.&lt;/p&gt;

&lt;p&gt;The fix was to stop showing 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 it says how many it hid. From the Oct 29 re-run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A volume filter isn't a verdict, and that's visible here. BLUR cleared the $1M bar and still showed about -1,071% APR, with Binance and Bybit predicting even more negative rates. The filter just stops the very thinnest books from taking over the list. &lt;code&gt;--min-volume 0&lt;/code&gt; shows everything, and that's where GMT, POLYX and GAS sat, between -65% and -75% APR on under $120k of volume each.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The API had a bad few minutes, and the old prompt only retried 429s
&lt;/h2&gt;

&lt;p&gt;During the first watch, &lt;code&gt;/info&lt;/code&gt; returned 502s and then 15-second read timeouts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first build's &lt;code&gt;post_info&lt;/code&gt; retried only on HTTP 429. That build happened to wrap the loop in &lt;code&gt;try/except&lt;/code&gt;, so it limped on, but four loops in a row were simply lost, and the prompt never asked for either behaviour. The next AI build could just as easily have crashed on the first 502.&lt;/p&gt;

&lt;p&gt;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. &lt;code&gt;watch&lt;/code&gt; catches it, prints &lt;code&gt;[HH:MM:SS UTC] loop N: API error (&amp;lt;reason&amp;gt;), retrying next loop&lt;/code&gt;, and carries on. There's a test for each part.&lt;/p&gt;

&lt;p&gt;One smaller fix from the same review: the first build's &lt;code&gt;print&lt;/code&gt; calls didn't flush. When stdout is a file instead of a terminal, Python block-buffers it, so &lt;code&gt;nohup python3 monitor.py watch &amp;gt; funding.log &amp;amp;&lt;/code&gt; can sit there looking empty. Every print in the rebuilt version uses &lt;code&gt;flush=True&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Funding moves during the hour, so naive alerts flap
&lt;/h2&gt;

&lt;p&gt;Hyperliquid's &lt;code&gt;funding&lt;/code&gt; field isn't a number that changes once an hour. It updates continuously. WLD was at -50.1% APR at 09:30 UTC and -54.2% at 09:44. A threshold alert that re-arms as soon as a coin dips below the line will fire again and again for a coin hovering near it.&lt;/p&gt;

&lt;p&gt;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. Here's the rebuilt version, 20 one-minute loops at 50%, webhook pointed at a local receiver, Oct 25:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;watching mainnet | apr &amp;gt;= 50% either sign | 24h vol &amp;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four alerts, each delivered once to the receiver, no repeats. Look at loop 17: 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.&lt;/p&gt;

&lt;p&gt;The Oct 29 re-run behaved the same way: BLUR and PURR alerted once on loop 1, loops 2 and 3 said &lt;code&gt;2 perps over, 0 new alerts&lt;/code&gt;, and the run ended with &lt;code&gt;2 alerts, 0 errors&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;fundingHistory&lt;/code&gt; answers a typo with HTTP 500 and &lt;code&gt;null&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The original prompt said an unknown coin would return an empty list. It doesn't. Asked for lowercase &lt;code&gt;eth&lt;/code&gt;, the endpoint returns this (re-checked Oct 29):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Coin names are case-sensitive, and a few are mixed case (&lt;code&gt;kPEPE&lt;/code&gt;, &lt;code&gt;kBONK&lt;/code&gt;), so "just uppercase it" doesn't work either. The rebuilt &lt;code&gt;history&lt;/code&gt; command first looks the name up in &lt;code&gt;{"type":"meta"}&lt;/code&gt;, case-insensitively, and sends the exact spelling. &lt;code&gt;history eth&lt;/code&gt; works, and &lt;code&gt;history nope&lt;/code&gt; prints &lt;code&gt;unknown coin nope on mainnet&lt;/code&gt; without calling &lt;code&gt;fundingHistory&lt;/code&gt; at all.&lt;/p&gt;

&lt;p&gt;History is where the long view lives. 600 hours of ETH is more than one 500-row page, so this also exercises the pagination (Oct 29):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;600 hours: mean +0.00080%/h (+7.0% APR simple), min -0.00248%, max +0.00125%, 301 hours at the 0.00125% baseline
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bonus: testnet funding is wild, but two columns are real
&lt;/h2&gt;

&lt;p&gt;Testnet books are thin, so Hyperliquid's own testnet funding is not realistic. On Oct 29, testnet SOL showed +4,087% APR and BTC -603%. But the Binance and Bybit columns in testnet's &lt;code&gt;predictedFundings&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The follow-up prompt
&lt;/h2&gt;

&lt;p&gt;If you built the monitor from an earlier version of the recipe, paste this into the same project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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 (&amp;lt;reason&amp;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 &amp;lt;name&amp;gt; on &amp;lt;net&amp;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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Starting fresh? The full prompt is on the recipe page. After it builds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install &lt;/span&gt;requests pytest
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; pytest &lt;span class="nt"&gt;-q&lt;/span&gt;
python3 monitor.py snapshot &lt;span class="nt"&gt;--top&lt;/span&gt; 15 &lt;span class="nt"&gt;--threshold&lt;/span&gt; 50
python3 monitor.py snapshot &lt;span class="nt"&gt;--top&lt;/span&gt; 15 &lt;span class="nt"&gt;--min-volume&lt;/span&gt; 0
python3 monitor.py &lt;span class="nb"&gt;history &lt;/span&gt;ETH &lt;span class="nt"&gt;--hours&lt;/span&gt; 600 &lt;span class="nt"&gt;--last&lt;/span&gt; 12
python3 monitor.py watch &lt;span class="nt"&gt;--threshold&lt;/span&gt; 50 &lt;span class="nt"&gt;--interval&lt;/span&gt; 60 &lt;span class="nt"&gt;--max-loops&lt;/span&gt; 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rebuilt version has 14 tests, all offline with a fake HTTP session. They passed again on the Oct 29 re-run.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is and isn't
&lt;/h2&gt;

&lt;p&gt;The page's verified line, word for word: &lt;em&gt;Verified working on live data in read-only mode, Oct 25, 2026.&lt;/em&gt; Its stated limit: &lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The monitor has no key, no wallet and no signing library, and a test fails if any app module mentions &lt;code&gt;/exchange&lt;/code&gt;, &lt;code&gt;private_key&lt;/code&gt;, &lt;code&gt;eth_account&lt;/code&gt; or &lt;code&gt;Exchange(&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;The full recipe, with the complete prompt, step-by-step setup, the FAQ and links to every Hyperliquid doc we checked, is at &lt;strong&gt;&lt;a href="https://botcookery.com/recipes/hyperliquid-funding-rate-monitor/" rel="noopener noreferrer"&gt;botcookery.com/recipes/hyperliquid-funding-rate-monitor&lt;/a&gt;&lt;/strong&gt;. For the API basics (endpoints, asset IDs, rate limits) see our &lt;a href="https://botcookery.com/recipes/hyperliquid-api-starter-testnet/" rel="noopener noreferrer"&gt;Hyperliquid API starter&lt;/a&gt;, and for large trades and public positions, the read-only &lt;a href="https://botcookery.com/recipes/hyperliquid-whale-tracker/" rel="noopener noreferrer"&gt;whale tracker&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>tutorial</category>
      <category>blockchain</category>
    </item>
  </channel>
</rss>
