I spent two months and about $414 building twenty-odd trading bots for Kalshi. None of them made money. That part isn't interesting — most don't.
What's interesting is that near the end I finally worked out why, and the answer turned out to be a proof rather than a hunch. It closes off an entire family of strategies, and I could have saved myself both months if I'd done the algebra in week one instead of week nine.
So here it is, along with the practical things I learned about Kalshi's plumbing that aren't documented anywhere obvious.
Kalshi contracts settle at either $1.00 or $0.00. A YES contract and a NO contract on the same market are opposites: exactly one of them pays a dollar.
So if you hold one of each, you collect exactly $1.00, guaranteed. They don't cancel out to nothing — they cancel out to a synthetic dollar bill.
Now suppose the market is trading around 50¢. You rest two buy orders below the market:
buy YES at 40c
buy NO at 40c
total cost: 80c
If both fill, you paid 80¢ for something worth exactly $1.00. Twenty cents, riskless, no opinion about the outcome required. Repeat forever.
This is the point where you start doing arithmetic about how many 15-minute windows there are in a year.
You cannot buy both legs at the same instant for under a dollar. The order book won't
let you — YES_ask + NO_ask is always at least 100¢ by construction. That's
the same identity that makes naive market-making pointless: buy both sides at the touch
and you've paid exactly 100¢ for a dollar.
So the 20¢ only exists if the two fills happen at different times — one when price dips, one when it rallies.
And that's where it breaks. At expiry, the price doesn't drift to a stop somewhere in the middle. It runs all the way to 0 or all the way to 100. On the way, it runs straight through your order on the losing side.
market settles NO price falls to 0 sweeps your YES bid at 40 fills, worthless
market settles YES price climbs to 100 sweeps your NO bid at 40 fills, worthless
The leg that loses is guaranteed to fill, because convergence drags the price right through it. The leg that wins only fills if price happened to visit that side earlier and come back.
In my data, only 2 of 54 windows had no fill at all — something almost always fills, and it's almost always the wrong thing. Stranded legs lost essentially their entire cost: they won about 1 time in 13.
Call the offset k — how many cents below the market you rest each order.
What you need. A completed pair pays 2k. A stranded leg
costs roughly 50 − k. Break-even requires:
p · 2k = (1 − p) · (50 − k) -> p* = (50 − k) / (50 + k)
What you get. In a fairly priced market, price is a martingale — a
fair game with no drift. Starting at 50, you must touch either 50−k or
50+k before settling, because reaching 0 requires passing below
50−k and reaching 100 requires passing above 50+k.
Say you touch 50+k first. The chance of then reaching 50−k
before absorption at 100 is, by optional stopping:
(100 − (50+k)) / (100 − (50−k)) = (50 − k) / (50 + k)
Symmetric if you touch the low side first. So:
p_actual = (50 − k) / (50 + k) = p*
The same formula. The probability of the trade working and the probability you need for it to break even are identical expressions. They cancel. Expected value is exactly zero at every offset — 5¢, 10¢, 20¢, 30¢, all of them.
Rather than trust the algebra, I registered the predictions in advance and ran two offsets side by side on real money, one contract at a time.
| Offset | Predicted | Observed | Lock should pay | Observed | P&L |
|---|---|---|---|---|---|
| 10¢ | 66.7% | 61.8% | +20.0¢ | +20.0¢ | −$0.59 |
| 20¢ | 42.9% | 44.1% | +40.0¢ | +40.0¢ | +$0.71 |
| total | +$0.12 | ||||
Sixty-eight settled markets. Doubling the offset moved the completion rate by eighteen percentage points and moved the profit by nothing. Completions paid their predicted amount to the cent, 36 times out of 36. The theory predicted 37.2 locks out of 68; reality produced 36.
Twelve cents of profit on sixty-eight trades. That's zero with a rounding error attached.
Anything assembled purely from fair-value resting orders:
The general version needs no algebra at all: every trade executed at fair value has an expected value of zero, and a portfolio of fair-value trades is still zero. No ordering, combination, or clever conditioning changes that. Structures that appear to escape it do so by hiding the completion probability somewhere you're not looking.
It also generalises past Kalshi. The proof only assumes price is a fair game and the contract settles at 0 or 100. That's true of any binary market on any venue.
What it does not rule out is having a genuinely better estimate than the market. If the price says 60% and the truth is 70%, buying at 60 makes money and no order-placement cleverness is required. That's a completely different problem, and a much harder one.
Independent of any strategy, here's what cost me time:
There are three fee tiers, not two. The published formula is
ceil(M × 0.07 × C × P × (1−P)) for takers and
ceil(M × 0.0175 × C × P × (1−P)) for makers. The taker multiplier defaults
to 1 — but the maker multiplier defaults to 0. So on most of the
exchange, resting orders are completely free. Around 76 series (mostly sports and the
headline economic releases) charge both sides. And about ten series have both multipliers
set to zero, meaning they trade entirely free — nothing in the interface
tells you which.
Break-even is simpler than people think. For a hold-to-settlement
trade it's just entry price + fee per contract. Buy at 50¢ paying a 1.75¢ fee
and you need 51.75%. That's the whole calculation, and the rate is identical whether you
trade one contract or a thousand.
Kalshi's published fee table is rounded for display — and I got caught by it. The table lists $0.02 for a single contract at 50¢ and $0.01 at 99¢. The actual charge rounds up to a centicent: $0.0175 and $0.0007. At the wings the table overstates the fee by fourteen times.
Order creation is POST /portfolio/events/orders. The
older /portfolio/orders returns 410. This broke three of my bots silently —
they kept running, kept logging, and placed nothing for days.
get_balance() returns a bare float, not a dict. I assumed
a dict, got None, and my daily-loss breaker failed open — a live bot
trading with no limit and no error message. Check the type.
There is no native NO order. Buying NO at 40¢ is submitted as
side: "ask", price: 0.60, and the UI displays it as "Sell Yes 60¢". Fills
record it as action=sell, outcome_side=no. Book the wrong field and your P&L
is wrong from day one.
Restarting a bot mid-window orphans its orders. The old process's resting orders survive, invisible to the new process, and they will fill. Cancel before restarting.
Contract counts are fractional. count_fp can return 0.02.
A dust fill got classified as a completed pair in my analysis and dragged an average from
+20¢ to +17¢, which sent me hunting a phantom fee for an hour.
The single most useful habit I picked up was writing the prediction down before looking at the result.
I ran roughly thirty strategies. Several looked spectacular in isolation: one showed a 100% win rate, +$1,288, a t-statistic of 5.92 — on three trades. Another produced a statistically significant calibration edge that dissolved the moment I used the right standard error. Two coins running identical settings on the same windows finished $2 apart, which reads as signal and is nothing but noise.
Thirty strategies from a zero-mean process will always produce a handful of winners. The only defence is a bar you committed to in advance, corrected for how many things you're looking at. When I finally applied one across the whole fleet, nothing was significantly profitable — but three things were significantly unprofitable, which was worth knowing and had been sitting in the data for weeks.
Two months to learn that the arithmetic was against me the whole time. Cheap, in the end, compared to the version where I'd scaled up first.