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 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.

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.

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.

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.

Bug 1: a restart reset the exposure limit

The limit was max_open_exposure_usd = 10. The retrofit tracked exposure as a counter in memory, starting at zero:

class State: def __init__(self): self.exposure_usd = 0.0 self.realized_pnl_today = 0.0 self.order_times = [] 

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:

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 > max_open_exposure_usd order group left in place: mock-60c841c9 closed Polymarket US positions; bye 

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.

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

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 > max_open_exposure_usd 

It starts at $9.55, where the last run left off, not at $0.

Bug 2: the daily-loss stop was dead code

The retrofit had a daily-loss stop, and it had a test, and the test passed. Here's the function:

def record_fill_pnl(pnl, state=STATE): """Call with realized PnL from each closing fill. Trips the daily loss stop.""" state.realized_pnl_today += pnl if state.daily_loss() >= LIMITS["max_daily_loss_usd"]: open(os.path.join(ROOT, "KILL"), "w").write("daily loss stop\n") 

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.

The fix: 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 state.json, 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.

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):

 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 

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.

Bug 3: amends skipped every check

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 check_order, and neither counted toward max_orders_per_minute. The old prompt only asked about order creation, and its test only looked there.

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.

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

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 check_order and counted against exposure only for the 10-cent difference.

Bug 4: the no-keys cancel-all only printed

With no keys, the old cancel_all.py had nothing to talk to, so it printed what it would have sent. This is the whole output:

[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 

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.

The fix: with no keys, the mock really cancels, and cancel_all.py 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: canceledOrderIds in the response isn't proof, the follow-up GET /v1/orders/open is. Here's touch KILL and python cancel_all.py in the rebuild:

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 

Bug 5: a new Kalshi order group every run

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.

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

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

order group mock-d5135215 is triggered. Reset it by hand, then restart. 

That's the behaviour you want. A kill switch that a restart quietly undoes isn't one.

Bug 6: shutdown sent a market order and said "closed" when it wasn't

This was the one that worried us most. Here's the retrofit's shutdown handler:

def shutdown(*_): for s in cfg["pm_slugs"]: try: venues.pm_close(P, s) except risk.RiskError as e: print(f" close blocked {s}: {e}", flush=True) print(f"order group left in place: {venues.ORDER_GROUP_ID}", flush=True) print("closed Polymarket US positions; bye", flush=True); sys.exit(0) 

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:

{"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"}} 

On the third run we touched KILL. check_order blocked the closes, and the last line still claimed success (cycle lines trimmed):

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 

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.

The fix: shutdown runs cancel_all --no-trigger, 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:

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 

One trap for any guard: same path, two meanings

Kalshi creates orders with POST /portfolio/events/orders and cancels all of them with DELETE 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 check_order", either blocks your cancel-all or misses your creates. Match method and path together.

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.

The follow-up prompt

Built this before Oct 25? Paste this into the same project:

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. 

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:

  • Run the tests. Then delete check_order from one amend function and run them again. The ast test should fail; if it doesn't, it isn't checking amends.
  • Stop the bot with orders resting and start it again. Exposure should start where it left off, not at $0.
  • touch KILL, confirm no new orders, run python cancel_all.py and look for "after: 0" on both venues.
  • 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.

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).

What this is and isn't

The page's verified line, word for word: 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.

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 order_group_contracts_limit 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.

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 botcookery.com/recipes/trading-bot-kill-switch-position-limits.