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: a legacy order field was not a fill ledger
The legacy order-status response exposed a filled_price field. In the production sample that triggered this audit, that field was None on effectively every filled order. The durable execution record is the per-fill data returned by GET /portfolio/fills. Kalshi's current V2 create-order response can also return average_fill_price for fills caused immediately by that request, but later partial fills still require reconciliation from the fill ledger.
The lesson is not to depend on an incidental order-response field as the accounting source of truth. A bot must reconcile the complete set of fill records for an order. 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
In the legacy integration we audited, filled_price was always None, so that or limit_price became the production path on every trade. If a 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), it can book 60–70¢ of phantom cost per contract. Multiply that across a few hundred trades and a large fictional loss appears in the 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:
from decimal import Decimal
def resolve_fill_price(api, order_id, limit_price_dollars, log):
fp = api.get("/portfolio/fills", params={"order_id": order_id})
fills = fp.get("fills", [])
if fills:
# Volume-weighted average across partial fills, in
# YES-equivalent dollars. Track each fill's is_taker flag
# too — it drives the fee, and maker/taker misallocation
# is its own accounting error.
qty = sum(Decimal(f["count_fp"]) for f in fills)
vwap = sum(
Decimal(f["yes_price_dollars"]) * Decimal(f["count_fp"])
for f in fills
) / qty
return vwap
log.warn("fills empty for %s — falling back to limit price", order_id)
return Decimal(limit_price_dollars)
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 /portfolio/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 /portfolio/fills as the durable source of truth for execution prices and fees. A create-order response only covers fills generated while that request is processed; the fill ledger covers later partial fills too. That distinction 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 /portfolio/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 was Kalshi's filled_price field None in our bot?
The legacy order-status field was not a reliable fill ledger in the integration we audited. Current V2 create-order responses may include average_fill_price for immediate fills, but complete accounting still needs the per-fill records from GET /portfolio/fills, including later partial 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 /portfolio/fills surfaces it.
How do I fix it in my own Kalshi bot?
Resolve fill price from GET /portfolio/fills?order_id= as a volume-weighted average across count_fp and yes_price_dollars, 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 that pages a human on any anomaly.
Start building — usually 20–40 seconds
Go straight to the full product from $40/month, or inspect the guided builder first. Leave an email only if you want it prefilled for a future draft plus one follow-up with plan and preview links.
Optional: this prefills the address if you save a builder draft later and sends one follow-up within a day. It does not create an account or enroll you in a recurring campaign; every follow-up includes an unsubscribe link.