Originally published on BotCookery, 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.
The BotCookery Claude trading-bot recipe is a prompt that sets up Claude Code safely (a CLAUDE.md rules file, permissions.deny rules for .env and key files, a sandbox denyRead on the key folder) and then scaffolds a small Kalshi demo 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.
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 state/paper.json forever. The bot said "holding" on every loop, and that dead order kept counting toward the exposure cap.
What we built
The prompt asks for these files: broker.py, risk.py, bot.py, cancel_all.py, find_ticker.py, config.toml and tests/. Python 3.11+, requests, cryptography and pytest only.
-
broker.pyhardcodes the demo base URLhttps://external-api.demo.kalshi.co/trade-api/v2. Production is a separate, deliberate change. - 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
state/paper.jsonso the same rules survive a restart. -
bot.pywatches one ticker and amax_pricefromconfig.toml. The rule is simple: the best YES ask is at or belowmax_priceand 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. -
risk.pychecks before every order and fails closed: theKILLfile orBOT_KILL=1, max contracts, open exposure computed from the exchange each time, daily loss fromGET /portfolio/balance, and orders per minute from timestamps saved instate/. - Orders go to
POST /portfolio/events/orderswithside"bid"(buy YES),count"1.00", a price like"0.3500", aclient_order_id,good_till_canceledandself_trade_prevention_typetaker_at_cross.cancel_all.pyisDELETE /portfolio/events/orders.
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.
What held from Oct 25
In 20 loops on KXATPCHALLENGERMATCH-26NOV09AGUSAN-AGU (ask $0.43, max price $0.45), the bot logged one dry_run, then holding 19 times. A restart kept the paper order and the per-minute timestamps. The kill file, BOT_KILL=1, the exposure cap and the per-minute limit (seeded, then a restart) all blocked as they should. A closed market was skipped. cancel_all.py with no keys exited 1 and said nothing was sent.
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 ~/.config/kalshi/ in its deny rules, and searched 1 page of 217 for tickers. None of that came back.
What broke on live demo data
A 429 ignored Retry-After. Through a local proxy that answered 429 with Retry-After: 5, 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.
An HTML 200 was a cryptic crash. An HTML maintenance page with status 200 logged JSONDecodeError: Expecting value: line 1 column 1. That error tells a beginner nothing about what happened. A dropped connection wasn't retried at all.
A typo'd ticker looped forever. It logged a 404 every 15 seconds, with no end.
A paper order outlived its market. In a dry run, an order on a market that later closed stayed in state/paper.json. 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.
Ctrl-C and | head crashed. Ctrl-C printed a traceback. With python bot.py | head -1, the closed pipe was caught by the holding check and logged as holding check error: BrokenPipeError, then the bot crashed anyway. With output sent to a file, nothing showed up until the bot exited, because Python buffers it.
find_ticker.py printed 8,046 lines. 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.
What we changed in the prompt
- Every request gets
timeout=(5, 10). Reads and cancel-all retry up to 2 times on 429, 5xx, timeouts and dropped connections. On 429 the bot waits theRetry-Afterseconds if sent (cap 8 s), else 1 s then 2 s. - An order POST is never retried.
- A reply that isn't JSON is a one-line error: the status code plus the first 80 characters with HTML tags stripped.
- Kalshi's
error.codeis logged as-is. Since Nov 5, 2026 an order on a halted or closed market returnsmarket_inactive(it used to bemarket_closed). - A 404 on
GET /markets/{ticker}stops the bot with "ticker not found, run find_ticker.py". - If the market isn't
activeand there's a paper order for it, the bot drops it and logspaper_dropped. - Every decision is printed with
flush=True, outside any try block that catches errors. Ctrl-C exits with "Stopped (Ctrl-C)." and no traceback; a closed pipe exits quietly. -
find_ticker.pytakes--max-priceand--top(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.
Drill results after the fix
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):
{"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"}
The rest of the drills:
- 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.
- Typo ticker: stops with exit code 1.
- Ctrl-C: exit code 130, no traceback.
-
python bot.py | head: exit code 0, quiet. -
find_ticker.py --max-price 0.45: 3,719 usable markets, printed as the 25 soonest to close instead of 8,046 lines.
How to use it
- Open an empty folder in Claude Code. Paste the prompt from the recipe page, with the Safety Pack rules file at the bottom.
- Run step 0 only. Approve the write to
.claude/settings.json, then check the deny rules,/statusand/sandbox. - Let it build step 1, and ask it to fetch the Kalshi docs pages first so it uses the current V2 order endpoint.
- Run
python find_ticker.py --max-price 0.50and pick a ticker that doesn't close today. Demo books are thin. - Read
broker.pyandrisk.pyline by line before running anything. - Dry-run with no keys.
touch KILLand confirm the next loop sends nothing. Deletestate/paper.jsonto start over.
Built this before Nov 9? The recipe page has a follow-up prompt for the same project:
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.
If you built it before Oct 25, paste the Oct 25 follow-up from the page first, then this one.
Limits
The page's verified line, word for word: 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.
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.
The full recipe, with the complete prompt, both follow-ups, the safety checklist and every doc we checked, is at botcookery.com/recipes/claude-trading-bot.
API changes and new field notes: @botcookery on X (https://x.com/botcookery)
Top comments (0)