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}
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))"
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()])"
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);
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
-
minVolumeis 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.
minOpenInterestfor a Kalshi-only run,minLiquidityfor 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,closingorprobability— 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_dollarsof 1.0000, settling at between 0 and 1 dollar. Polymarket'svolumeNumcounts 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. Theprobabilityfield 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}'
(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)