If your Kalshi bot's reported P&L looks worse than the cash actually missing from your account, you are almost certainly hitting a platform gotcha that silently corrupts execution accounting in most bots β including, until we caught it, ours. Here is exactly what it is, why it hides from normal audits, and how to detect and fix it in any Kalshi bot.
This is a companion to our Kalshi API tutorial and the broader guide to Kalshi trading bots. It assumes you have a bot placing real orders and reconciling its own P&L.
The symptom
Your bot reports a cumulative loss that is materially larger than the real cash decline in your Kalshi balance. The per-trade math looks internally consistent β wins, losses and fees all add up within each row β and yet the lifetime number is inflated. In our own engine the displayed figure was roughly 2.4× the reconciled reality (it showed about −$94 cumulative when the true realized loss was closer to −$38; gross trade P&L was near breakeven and fees were the only real drag). Same data, same arithmetic β wrong inputs.
The root cause: filled_price is empirically always None
Kalshi's order-status response has a filled_price field. Intuitively you would read it after an order fills and book that as your entry cost. The trap: for filled orders, filled_price is empirically None essentially 100% of the time. The real, volume-weighted fill price only lives on the per-fill records returned by the GET /fills endpoint β never on order-status.
This is not a transient bug on Kalshi's side; it is a stable microstructure fact about the platform. Any bot that reads fill prices from order-status will get None on every filled order and must have a fallback. The fallback is where the damage happens.
Why the fallback becomes the primary code path
A typical defensive resolver looks like this:
def resolve_fill_price(order_status, limit_price):
# "Paranoia rail" β should rarely trigger.
return order_status.get("filled_price") or limit_price
Because filled_price is always None, that or limit_price is not a rare fallback β it is the production code path on every single trade. If your bot crosses the spread aggressively (a marketable limit such as "buy YES up to 82¢" that actually fills at 5–15¢ on a deep-underdog contract), you book 60–70¢ of phantom cost per contract. Multiply by a few hundred trades and you have a large, entirely fictional loss sitting in your records.
The correct resolution queries the fills endpoint and only falls back to the limit price as a genuine last resort β with an explicit warning so a silent-primary-path can never recur:
def resolve_fill_price(api, order_id, limit_price, log):
fp = api.get("/fills", params={"order_id": order_id})
fills = fp.get("fills", [])
if fills:
# Volume-weighted average across partial fills, in
# YES-equivalent cents. Track each fill's is_taker flag
# too β it drives the fee, and maker/taker misallocation
# is its own accounting error.
qty = sum(f["count"] for f in fills)
vwap = sum(f["yes_price"] * f["count"] for f in fills) / qty
return vwap
log.warn("fills empty for %s β falling back to limit price", order_id)
return limit_price
Why it survives normal audits
This bug is insidious because the obvious checks all pass:
- Per-row consistency holds.
realized = gross − feesis true on every row. The arithmetic is correct; only the inputs are wrong, so internal-consistency tests stay green. - Ticker-aggregate reconciliation is blind to it. The common audit groups fills by ticker and compares a Kalshi-derived aggregate against Kalshi's own reported realized P&L. Both sides come from Kalshi, so the comparison never inspects what your bot actually stored. It is Kalshi-versus-Kalshi; the corrupted value hides in the middle.
- The numbers look plausible. A bot doing ~1,000 trades a week with a three-figure cumulative loss seems in range for a known-iffy strategy. Nothing screams "accounting bug."
The only thing that catches it is a per-record reconciliation: for each settled trade, recompute realized P&L from that order's actual fills and compare to the stored value.
How to detect it in your bot
Add a guardrail that runs daily over recently settled trades:
def audit_records(rows, api, tolerance_cents=10):
drift = []
for r in rows:
truth = realized_from_fills(api, r["order_id"]) # via /fills
if abs(truth - r["realized_pnl_cents"]) > tolerance_cents:
drift.append((r["id"], r["realized_pnl_cents"], truth))
return drift # non-empty => phantom accounting; alert a human
A ±10¢ tolerance absorbs legitimate per-slice fee rounding while still flagging the 60–70¢-per-contract phantom cost immediately. If drift is ever non-empty, page someone β do not just log it. A fire-and-forget warning is how this kind of bug lives for a week.
The general lesson
Treat Kalshi's GET /fills as the only source of truth for execution prices and fees. Order-status tells you that an order filled, not at what price. This single fact — filled_price is empirically None on filled orders — should be wired into every Kalshi bot's accounting layer from day one, not discovered after a few hundred trades of fictional losses.
It also reframes a common piece of bot-builder folklore. "My strategy loses money" is often, on inspection, "my accounting over-reports losses and the strategy is closer to breakeven than the dashboard claims." You cannot tune what you cannot measure. Fix the measurement first — then read our guide to Kalshi trading strategies with numbers you can trust.
If you would rather not build and maintain this reconciliation layer yourself, our non-custodial Kalshi bot resolves every fill from /fills with a per-record drift guardrail wired in by default.
Frequently Asked Questions
Quick answers to common questions about Why Your Kalshi Bot's P&L Is Probably Wrong.
Why does Kalshi's API return filled_price as None?
On the order-status endpoint, filled_price is empirically None for filled orders. Kalshi exposes the actual volume-weighted execution price only on the per-fill records from GET /fills. This is stable platform behavior, not a transient bug, so every bot that reads fill prices needs to source them from /fills.
How much can this phantom-loss bug distort P&L?
It depends on how aggressively your bot crosses the spread. A marketable limit that fills far below its cap books the gap as phantom cost on every trade. In our own engine the displayed cumulative loss was roughly 2.4x the reconciled reality before we fixed it.
Why don't normal audits catch this?
Per-row math stays internally consistent (realized = gross - fees holds), and ticker-aggregate audits compare Kalshi data against Kalshi data, never inspecting the corrupted value your bot stored. Only a per-record reconciliation against /fills surfaces it.
How do I fix it in my own Kalshi bot?
Resolve fill price from GET /fills?order_id= as a volume-weighted average across partial fills, track each fill's is_taker flag for correct fees, and fall back to the limit price only as a last resort with an explicit warning. Add a daily per-record drift check with a +/-10 cent tolerance that pages a human on any anomaly.
Free live webinar with John & Dave
June 4 · 6 PM Pacific — watch bots built live on Kalshi. Free 48-hour pass for every attendee.
Free for everyone — calendar invite sent instantly.