Originally published on BotCookery, 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.
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.
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.
None of them was a crash. Each one was the bot quietly doing the wrong thing with orders, limits or retries.
The Kalshi side in one paragraph
Orders go to POST /portfolio/events/orders (Create Order V2; Kalshi says the legacy /portfolio/orders will be deprecated no earlier than May 6, 2026). The body has ticker, side (bid or ask), count as a string, price as a fixed-point dollar string like "0.5600", time_in_force, self_trade_prevention_type, and optional client_order_id, post_only, reduce_only and cancel_order_on_pause. Order groups (POST /portfolio/order_groups/create) auto-cancel every resting order in the group if fills exceed a contracts limit in a rolling 15-second window. DELETE /portfolio/events/orders cancels everything. Demo lives at https://external-api.demo.kalshi.co/trade-api/v2, 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.
Here's what the mock broker logs when the rule fires (Oct 27 clean rebuild, trimmed):
{"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 > margin 0.03: bid 1 @ 0.1800", "risk": "ok", "event": "order sent (mock)"} The fair price of 0.25 is a placeholder in the config, not a view on the market.
Bug 1: a filled order got bought again
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.
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.
The fix: save the tickers ordered today in state.json, and also skip any ticker where GET /portfolio/positions 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:
{"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"} The next 41 loops on that ticker said "already ordered today" and sent nothing.
Bug 2: the orders-per-minute cap forgot itself on restart
max_orders_per_minute 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.
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 state.json. So is the 60-second wait after a cancel-all, which matters for a specific Kalshi reason: the docs for DELETE /portfolio/events/orders 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.
Bug 3: a typo'd ticker was retried forever
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:
{"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\"}}"} 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:
{"ts": "2026-10-27T14:20:58+00:00", "ticker": "KXBOTCOOKERY-NOTREAL", "event": "ticker not found (404): skipping it from now on"} In the clean rebuild that line appeared once per process, five processes in all, instead of 126 times.
Bug 4: a closed market's order stayed in exposure forever
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.
The mock now drops resting orders when a market's status is no longer active, and logs it. In the longer live run we watched short-lived demo markets go initialized to active to closed to determined, and the new code caught it:
{"ts": "2026-10-27T14:00:13+00:00", "ticker": "KXQUICKSETTLE-27OCT26H1000-3", "event": "market closed: 1 resting order(s) now gone"} The point generalizes: if your mock is more forgiving than the exchange, your risk checks pass for the wrong reason.
Bug 5: retries ignored the server and the clock
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 Retry-After: 5 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.
The client now retries only GETs (never an order), honours Retry-After 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 Retry-After: 5 took 40 seconds, 503 took 16, a dropped connection 16.
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.
Here's the clean rebuild against the relay returning 429 with Retry-After: 5:
{"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"} 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.
Also new: the fee comes off the edge
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 fee 0.0103 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.
The first pass, Oct 24: three bugs that came first
Restarting stacked a second order. 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.
Daily loss came from the wrong place. The old prompt computed it from GET /portfolio/positions as realized P&L minus fees. That endpoint returns unsettled positions by default, and its P&L is per market, not per day. Fed to the risk module as written:
old unsettled loss counts as today: 2.15 after settlement it vanishes : 0.0 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 GET /portfolio/balance: equity (balance plus portfolio value) at the first loop of the UTC day, minus equity now, saved in state.json.
Resting orders didn't count as exposure. 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.
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 ?status=open, but each market comes back as active. And from our server, external-api.kalshi.com returned 403 while api.elections.kalshi.com answered, which is why the production base URL is overridable.
Rebuilt from scratch: what held
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:
another copy of the bot is already running: refusing to start When a market closed, the mock dropped its resting order and logged it. And one gap in the prompt itself turned up: a Retry-After 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.
The follow-up prompt
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.)
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. Starting fresh? The full prompt is on the recipe page. Do the Kalshi API starter 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:
BROKER_MOCK=1 python bot.py 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: touch KILL cancels everything once and stops ordering, removing it waits 60 seconds, and deleting an order from logs/mock_state.json simulates a fill that must not be bought again.
What this is and isn't
The page's verified line, word for word: 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.
Some changes are covered by unit tests only, not by the live run: the stop when the order group is rejected, limits.json failing closed if a value is missing or not positive, and paginated resting_orders. 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.
The full recipe, with the complete prompt, step-by-step setup, the safety checklist and links to every Kalshi doc we checked, is at botcookery.com/recipes/kalshi-ai-trading-bot. The code from the verified run is on GitHub at lazyclaw137-ship-it/kalshi-ai-trading-bot (MIT, no keys), and the three-layer kill switch is written up on its own in our kill switch and position limits recipe.

