DEV Community

Matvey
Matvey

Posted on

A Liquidity Floor Deleted Kalshi. An Open-Interest Floor Deleted Polymarket. Same Board, Same Hour

I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them merges the two big prediction markets into a single table: Kalshi, the CFTC-regulated US exchange, and Polymarket, the crypto-settled global one. Same field names for both, so you can sort the whole crowd-pricing board at once instead of writing two clients.

Merging two venues into one schema is mostly boring work — rename volumeNum to volume, agree on one date format. The part that is not boring is what happens when a field exists on one venue and not the other. You cannot leave a hole in a table, so you put a null in it. And then someone filters on that column.

Two filters, one board, mirror-image emptiness

On 23 October 2026 I ran the same request three times inside an hour. Both venues, markets mode, sorted by 24-hour volume, ten rows. The only difference was the depth filter.

No filter at all:

# Platform volume24h Question
1 kalshi 4,857,996 Game 1: Tampa Bay vs Milwaukee — Milwaukee
2 polymarket 1,316,513 No change in Fed rates after the October meeting?
3 kalshi 1,484,760 Texas Senate winner? — Ken Paxton
4 polymarket 729,183 US × Iran ceasefire continues through October 22?
… … … five more, alternating

Five from each. Now the same call with "minLiquidity": 10000 — a floor I expected to strip out the dead contracts both venues list by the thousand:

{"platforms": ["kalshi", "polymarket"], "mode": "markets",
 "sortBy": "volume24h", "minLiquidity": 10000, "maxMarkets": 10}
Enter fullscreen mode Exit fullscreen mode

Ten rows came back. All ten were Polymarket. Not one Kalshi market survived, including the contract on which party wins the US House — a market carrying 33,159,843 contracts of open interest.

Then the same floor moved to the other depth column, "minOpenInterest": 10000:

Ten rows, all ten Kalshi. Zero Polymarket.

Two filters that both read as "only markets with real money in them" each quietly removed an entire exchange, in opposite directions, from the same board in the same hour.

Why: 39 fields and 89 fields with nothing in common

This is not a scraping failure, and it is not the venues being flaky. It is a vocabulary problem, and you can see it in two curl calls.

Kalshi's public market object, straight from the exchange:

curl -s "https://api.elections.kalshi.com/trade-api/v2/events?limit=1&with_nested_markets=true&status=open" \
  | python3 -c "import sys,json; m=json.load(sys.stdin)['events'][0]['markets'][0]; print(len(m), sorted(m))"
Enter fullscreen mode Exit fullscreen mode

39 fields. Searching them for liquid returns nothing — the word does not appear. What Kalshi does publish is open_interest_fp (how many contracts are still open), volume_fp, volume_24h_fp, and the size resting at the very top of the book, yes_bid_size_fp and yes_ask_size_fp.

Polymarket's market object from the Gamma API:

curl -s "https://gamma-api.polymarket.com/markets?limit=1&closed=false&order=volume24hr&ascending=false" \
  | python3 -c "import sys,json; m=json.load(sys.stdin)[0]; print(len(m), [k for k in m if 'liquid' in k.lower() or 'interest' in k.lower()])"
Enter fullscreen mode Exit fullscreen mode

89 fields, and three of them are liquidity: liquidity, liquidityNum, liquidityClob — all three the same $1,205,305 on the row I checked. Open interest: not present. Not under that name, not under any name.

So the overlap between the two venues' depth vocabulary is empty. One exchange tells you how much is resting in the book; the other tells you how many contracts are still alive. Both are honest measures of "is anyone actually here", and they are not convertible into each other.

The one line that turns a depth filter into a venue filter

My own code is where the surprise got manufactured. The filter was this:

const passesDepth = (row) => (row.volume || 0) >= minVolume
    && (!minLiquidity   || (row.liquidity   || 0) >= minLiquidity)
    && (!minOpenInterest || (row.openInterest || 0) >= minOpenInterest);
Enter fullscreen mode Exit fullscreen mode

Read the middle line with a Kalshi row in hand. row.liquidity is null, because Kalshi never sent the field. null || 0 is 0. 0 >= 10000 is false. Row dropped. Every Kalshi row, every time, no matter how busy.

|| collapsing a missing value into a real zero is the oldest JavaScript footgun there is, and usually harmless. Not here, because missing and zero mean opposite things: zero liquidity means nobody is quoting, missing liquidity means the exchange never answers that question. The filter cannot tell them apart, so it treats a whole venue as a graveyard.

A filter on a column one source does not populate is not a filter. It is a WHERE platform = ..., written in a way nobody reads as one.

Sorting on it is worse, because nothing looks wrong

A filter at least produces visibly one-sided output. Sorting produces output that looks fine.

Same board, "sortBy": "liquidity", ten rows:

# Platform liquidity volume24h Question
1 kalshi null 32,162 Butterfield Bermuda Championship Winner — Kihei Akina
2 polymarket 4,322,642 551,363 Democratic Party control the House after 2026?
3 kalshi null 2,334 Butterfield Bermuda Championship Winner — Matthew Riedel
4 polymarket 3,811,149 515,836 2026 Balance of Power: D Senate, D House
5 kalshi null 134,916 Butterfield Bermuda Championship Winner — Aaron Baddeley
7 kalshi null 90,540 Butterfield Bermuda Championship Winner — Zac Blair
9 kalshi null 74,258 Butterfield Bermuda Championship Winner — Chandler Phillips

The Polymarket half is sorted properly, 4.32M down to 3.04M. The Kalshi half is all ties at null, so a stable sort hands back whatever order the rows were collected in — which on that run meant five long-shot golfers from one tournament, one of them with $2,334 of 24-hour volume, sitting in a column headed "most liquid markets". Put that table in a newsletter and nobody will query it. It has numbers in it, it has a sensible-looking mix of venues, and half of it is arbitrary.

What I actually pass now

  • minVolume is the only depth floor safe to apply across both venues. Both publish volume, both publish a 24-hour figure, and that is the whole shared vocabulary. A floor of 1,000 kills most of the noise on both boards.
  • Use the venue's own measure one venue at a time. minOpenInterest for a Kalshi-only run, minLiquidity for a Polymarket-only run. Two runs, then concatenate — not one run with both floors, which returns nothing at all.
  • Never sort a merged table by a column only one side fills. Sort by volume24h, closing or probability — the fields both venues answer — or sort inside each venue after the fact.
  • Compare probabilities across venues, never volumes. Kalshi's volume counts contracts, each with notional_value_dollars of 1.0000, settling at between 0 and 1 dollar. Polymarket's volumeNum counts dollars that changed hands. A Kalshi market showing 4.8M and a Polymarket market showing 1.3M are not 3.7× apart; they are measured in different units, and the ratio between them is the average trade price, which you do not know. The probability field is the one number that means exactly the same thing on both sides.

Reproducing any of the above is one request. The Actor is Prediction Markets API; each of the four runs in this post returned ten rows and cost $0.015, which the run's own free summary row tells you before you go looking in the billing tab:

curl -X POST "https://api.apify.com/v2/acts/lergassy~prediction-markets-api/run-sync-get-dataset-items?token=<TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"platforms":["kalshi","polymarket"],"mode":"markets","sortBy":"volume24h","minVolume":1000,"maxMarkets":10}'
Enter fullscreen mode Exit fullscreen mode

(Filter type != "notice" and you have only market rows.)

When this Actor is the wrong tool

If you only ever read one venue, go single-venue and filter on its native depth field without the translation layer. If you need order-level depth rather than a summary number, the order book is a separate call per market, and per-row billing makes a thousand books expensive. And if you want to know whether a Kalshi question and a Polymarket question are the same bet, no field will tell you: wording gets you candidates, resolution rules decide. That is the next thing on my list to measure.

The general lesson is older than prediction markets, and I keep relearning it: when you normalise two sources into one schema, the nulls are the schema. Every filter and every sort you expose has to be told which columns are real on which source, or you will ship a table that silently answers a different question than the one you asked.

Top comments (0)