DEV Community

Matvey
Matvey

Posted on

75 of 200 Kalshi Rows Said Exactly 50%. All 75 Had Already Settled

I run a visa agency in Bali. The rest of my week goes into Apify Actors, and one of them reads Kalshi — the CFTC-regulated US event exchange where a contract pays $1 if something happens, so its price is a probability. One call, one table: question, implied probability, best bid and ask, volume, open interest, close date.

Last night I ran it the way a user with a newsletter would run it — "show me what resolves soonest" — and the output was absurd in a way I had shipped myself.

The run

{
  "mode": "markets",
  "sortBy": "closing",
  "maxMarkets": 200,
  "includeClosed": false
}
Enter fullscreen mode Exit fullscreen mode

3.4 seconds end to end, 200 rows, $0.30 billed. Here is row one, trimmed:

{
  "marketId": "KXHORMUZNORM-26MAR17-B260815",
  "question": "When will traffic at the Strait of Hormuz return to normal? — Before Aug 15, 2026",
  "probability": 50,
  "yesPrice": 0.5,
  "noPrice": 0.5,
  "bestBid": 0,
  "bestAsk": 1,
  "spread": 1,
  "lastPrice": 0.01,
  "volume": 1059488.36,
  "status": "closed",
  "result": "no"
}
Enter fullscreen mode Exit fullscreen mode

Read the fields against each other. The row says the market is a coin flip. The row also says the last trade happened at one cent, the market is closed, and it settled no. Those are not two readings of the same thing. One of them is a measurement and the other is an artefact.

I counted across the whole run:

count
rows delivered 200
status: "open" 125
status: "closed" 75
probability exactly 50 75
spread exactly 1.00 75

The same 75 rows in all three lines. Not a coincidence, an identity.

Averaging an empty book

Kalshi reports the best bid and the best ask per contract. When a contract has finished trading, the book is empty — and Kalshi expresses "empty" as the widest legal quote there is: bid 0.00, ask 1.00. You can buy at a dollar and sell at nothing, which is another way of saying nobody is quoting.

My price function did what every price function does:

const yes = num(m.yes_bid_dollars) != null && num(m.yes_ask_dollars) != null
    ? (num(m.yes_bid_dollars) + num(m.yes_ask_dollars)) / 2
    : num(m.last_price_dollars);
Enter fullscreen mode Exit fullscreen mode

Midpoint of a two-sided quote, last trade as the fallback. Perfectly ordinary, and wrong in exactly one case: the midpoint of 0.00 and 1.00 is 0.50. The fallback never fires, because both sides are present. They are just both meaningless.

That is the nastiest shape a bad value can take. A null would have been caught by whoever consumed the row. A 50 passes every sanity check a downstream script could reasonably apply: it is a number, it is in range, it even sorts. The 75 rows the exchange was silent about became the 75 rows my table was most confident about.

What those markets actually last traded at:

0.001  ×34
0.01   ×34
0.002  ×5
0.003  ×1
0.99   ×1
Enter fullscreen mode Exit fullscreen mode

Fifty-nine had already settled no. Sixteen had stopped trading with the result still pending — and one of those sixteen, "Where will it rain on Oct 19, 2026? — West Palm Beach", last traded at 99 cents. The market was as sure as a market gets. My row called it a coin flip.

Why settled contracts were in the output at all

The input said includeClosed: false. So there is a second bug under the first one, and it is a more interesting mistake.

To find the boards people actually trade, the Actor hits Kalshi's own series search with status=open and gets back event tickers, then fetches each event whole. From the run log:

Kalshi search: read 72 of 200 busiest open event(s), 1505 market(s) kept.
Enter fullscreen mode Exit fullscreen mode

The filter is applied by Kalshi, at the event level, and it is honest at that level. An event is open while anything under it still trades. But an event is a question and a market is one answer to it, and answers die one at a time. Fourteen of the 75 stray rows came from a single event titled "Who will win the Ballon d'Or in 2026?" — fourteen candidate contracts, all resolved, inside an event the exchange still lists as open. Another 28 came from "Pro Baseball Champion", 16 from a rain event whose day was over.

I had trusted an upstream filter across a level of nesting it does not reach. That is the general version of the bug, and it has nothing to do with Kalshi.

Then the sort decided how much it hurt. A contract that has already closed, closed in the past, so "closing soonest" puts every single one of them ahead of every live market by construction. Same build, same filter, four different sorts:

sortBy rows already settled
closing 200 75 (37.5%)
move24h 100 2
volume24h 100 1
probability 100 0

For months the leak was a rounding error on the default sort, which is why nothing flagged it. On the one sort where a user is explicitly asking "what resolves this week", it ate better than a third of the output — and the user pays per row, so that was 11 cents of billed history per 200-row run.

Two fixes

The price first. A settled contract is worth its result; otherwise take a real two-sided quote; otherwise the last trade; otherwise whichever single side exists:

export function kalshiYesPrice(m) {
    const status = String(m.status || '').toLowerCase();
    const result = String(m.result || '').toLowerCase();
    if (KALSHI_CLOSED.includes(status) || result) {
        if (result === 'yes') return 1;
        if (result === 'no') return 0;
    }
    const bid = num(m.yes_bid_dollars), ask = num(m.yes_ask_dollars), last = num(m.last_price_dollars);
    const hasBid = bid != null && bid > 0;
    const hasAsk = ask != null && ask > 0 && ask < 1;      // ask 1.00 is "no offer", not an offer
    if (hasBid && hasAsk && ask >= bid) return (bid + ask) / 2;
    if (last != null && last > 0) return last;
    if (hasBid) return bid;
    if (hasAsk) return ask;
    return null;
}
Enter fullscreen mode Exit fullscreen mode

The ask < 1 is the whole repair: it demotes the sentinel from a quote to the absence of one.

Then the leak, one line where the event-level filter stops reaching:

const passesStatus = (row) => includeClosed || row.status === 'open';
Enter fullscreen mode Exit fullscreen mode

Applied per market, not per event.

After

Same input, same 200 rows, new build:

before after
status: "closed" 75 0
probability == 50 75 0
spread == 1.00 75 0
probability null 0 0

And the price path checked directly, old build against new, on three settled Strait-of-Hormuz contracts that all paid nothing:

build 0.1.24:   50   50   50      ← midpoint of bid 0.00 / ask 1.00
build 0.1.25:    0    0    0      ← result: "no"
Enter fullscreen mode Exit fullscreen mode

The check that mattered was the other direction: I pinned one series and pulled its ten live contracts from both builds, and every field came back identical — probability, bid, ask, spread, last price. The fix had to be invisible to every market that still has a book, and it is.

While I was in there I grepped for the same midpoint across the rest of my portfolio and found it in two more Actors reading the same exchange — and found that a third one had already been repaired weeks ago and the repair had never been carried back. A shared helper copied into four folders is four independent bugs wearing one name. All three are now on the fixed build.

Reproduce it on any exchange feed

The check is three lines and worth running against whatever venue you read, because the sentinel-as-quote trick is not a Kalshi quirk:

import requests

rows = requests.post(
    "https://api.apify.com/v2/acts/lergassy~kalshi-scraper/run-sync-get-dataset-items",
    params={"token": TOKEN},
    json={"mode": "markets", "sortBy": "closing", "maxMarkets": 200, "includeClosed": False},
).json()

suspect = [r for r in rows if r["bestAsk"] == 1 or r["bestBid"] == 0 or r["spread"] == 1]
print(len(suspect), "rows with no real quote")
for r in suspect[:5]:
    print(r["probability"], "vs last trade", r["lastPrice"], "|", r["status"], r["question"][:60])
Enter fullscreen mode Exit fullscreen mode

If probability and lastPrice disagree by fifty points on a row whose spread is the entire range, you are reading a placeholder. Any derived number — a sort by probability, a "biggest mover" alert, a mean across a category — carries it forward silently.

When this is the wrong tool

  • You want a forecast. The probability is what people pay after fees and risk appetite, not a model's estimate.
  • You need the resolution rules more than the number. Two near-identical questions can settle differently; the rules field carries the text, and reading it is not optional.
  • You need depth as a dollar figure. Kalshi leaves its own liquidity field at zero on nearly every market. openInterest is the number that means something.
  • You want history you did not collect. 90 days back, per market, and a contract listed last week has a week whatever you ask for. Nothing stores more than that for you.

The Actor is Kalshi Scraper & API — markets, events with every outcome, keyword search, minute-level price history and order books, no key and no browser, priced per row. Yesterday's 200-row run cost 30 cents and 3.4 seconds, and 11 cents of it was my own bug.

The lesson I am keeping: when a feed has to express "I have nothing to tell you", it will do it inside the type you were expecting. Find out what that value is before you average it.

Top comments (0)