How to Profit From Price Gaps Between Polymarket and Kalshi
When Polymarket and Kalshi price the same question differently, buy Yes on one and No on the other for less than $1.00 after both fees, and the pair pays $1.00 whatever happens. One Python script reads both platforms' public prices and does the fee math for you, and it tells you straight when today's answer is zero.
>This covers the arbitrage check. Polymarket Profits adds the multi-platform portfolio tracker, the 25% cluster cap that keeps five bets from being one bet, and the seasonal calendar for what to trade each quarter.

Hello builders,
Polymarket and Kalshi sometimes price the same question differently, and you can profit from that price gap without predicting anything, while a Python script hunts for the next one. Polymarket Kalshi arbitrage is buying Yes on one and No on the other when the two prices, plus both fees, add up to less than the $1.00 the pair is guaranteed to pay.
Here’s the made-up pair from my book. “Will the US enter a recession this year?” trades at $0.28 on Polymarket, and the No ask on Kalshi is $0.65. We pay $0.93 for two contracts, and exactly one of them pays $1.00.

If the recession happens, the Polymarket Yes pays $1.00 for a $0.72 profit and the Kalshi No goes to zero, a $0.65 loss, so we net +$0.07. If it doesn’t, we lose the $0.28 and the Kalshi No pays $0.35 of profit, and we net +$0.07 again. Then the fees come out. The Polymarket taker fee on the Yes leg, at the 0.04 rate the book uses for this made-up market, is 0.04 × 0.28 × 0.72, about $0.008. The Kalshi fee on the No leg, using the book’s figure for most Kalshi markets, is about 0.07 × 0.65 × 0.35, or $0.016. That leaves about 4.6 cents a pair, before the cost of tying the money up until the market resolves.
Price both legs at the ask
The easy mistake is pricing the No leg as 1 minus the Yes price, which makes gaps appear that you can’t trade. You pay the ask on both legs. When I pulled 200 open markets from the public Kalshi trade API every one carried its prices as dollar strings, yes_ask_dollars and no_ask_dollars. On one brand-new tennis match in that pull, the Yes ask was $0.9700 and the No ask was $0.9700, so buying both sides would have cost $1.94 for a pair that pays $1.00.
Save this as arb.py (Python 3.11 or newer, pip install requests, no keys):
import json, sys
import requests
GAMMA = "https://gamma-api.polymarket.com"
CLOB = "https://clob.polymarket.com"
KALSHI = "https://external-api.kalshi.com/trade-api/v2"
def best_ask(token_id):
book = requests.get(f"{CLOB}/book", params={"token_id": token_id},
timeout=30).json()
asks = [float(a["price"]) for a in book.get("asks", [])]
return min(asks) if asks else None
def poly_markets(limit=50):
params = {"closed": "false", "limit": limit,
"order": "volume24hr", "ascending": "false"}
out = []
for m in requests.get(f"{GAMMA}/markets", params=params, timeout=30).json():
try:
yes_id, no_id = json.loads(m["clobTokenIds"])
except (KeyError, TypeError, ValueError):
continue
yes, no = best_ask(yes_id), best_ask(no_id)
if yes and no:
rate = float((m.get("feeSchedule") or {}).get("rate") or 0)
out.append({"question": m["question"], "yes_ask": yes,
"no_ask": no, "fee_rate": rate})
return out
def kalshi_markets(limit=200):
params = {"status": "open", "limit": limit, "mve_filter": "exclude"}
out = []
for m in requests.get(f"{KALSHI}/markets", params=params,
timeout=30).json()["markets"]:
yes, no = float(m["yes_ask_dollars"]), float(m["no_ask_dollars"])
if 0 < yes < 1 and 0 < no < 1:
out.append({"question": m["title"], "yes_ask": yes,
"no_ask": no, "fee_rate": 0.07})
return out
def leg_cost(market, side):
p = market[f"{side}_ask"]
return p + market["fee_rate"] * p * (1 - p) # ask + taker fee
def markets_match(a, b):
wa, wb = set(a["question"].lower().split()), set(b["question"].lower().split())
return len(wa & wb) / max(1, min(len(wa), len(wb))) > 0.6
def find_arbitrage(poly, kalshi, min_profit=0.02):
found = []
for pm in poly:
for km in kalshi:
if not markets_match(pm, km):
continue
for yes_m, no_m, route in ((pm, km, "Yes Poly / No Kalshi"),
(km, pm, "Yes Kalshi / No Poly")):
profit = 1 - leg_cost(yes_m, "yes") - leg_cost(no_m, "no")
if profit >= min_profit:
found.append((round(profit, 3), route, pm["question"]))
return found
if __name__ == "__main__":
if sys.argv[1:] == ["test"]: # the made-up recession pair
pm = {"question": "us recession this year", "yes_ask": 0.28,
"no_ask": 0.80, "fee_rate": 0.04}
km = {"question": "us recession this year", "yes_ask": 0.40,
"no_ask": 0.65, "fee_rate": 0.07}
print(find_arbitrage([pm], [km]))
else:
poly, kalshi = poly_markets(), kalshi_markets()
found = find_arbitrage(poly, kalshi)
pairs = sum(markets_match(pm, km) for pm in poly for km in kalshi)
print(f"Polymarket {len(poly)} | Kalshi {len(kalshi)} | "
f"matched {pairs} | candidates {len(found)}")
for row in found[:5]:
print(row)
Run python3 arb.py test first. It feeds the script the made-up pair and should print [(0.046, 'Yes Poly / No Kalshi', 'us recession this year')], which is the 4.6 cents from the picture, so you know the fee math is right before you trust a live scan. Then run python3 arb.py for the real thing. The Kalshi rate is set to 0.07, the figure the book uses for most Kalshi markets, and the Polymarket rate comes from the feeSchedule on each market.
Zero is the usual answer
My last live scan printed Polymarket 34 | Kalshi 72 | matched 0 | candidates 0, and the test run in the book said Polymarket 46 | Kalshi 135 | candidates 0. The matched count tells you which kind of zero you got. A 0 there means no titles lined up on the two lists, so the scan never priced a pair, and a matched count above 0 with no candidates means the pairs it found were priced too close to pay after fees. The scan reads the 50 busiest Polymarket markets and the first 200 Kalshi markets, so raise limit in poly_markets and kalshi_markets to widen it. It also prices each leg at the best ask only and never checks how many shares sit there.
When the script does print a candidate, we check four things by hand before buying anything. Both markets have to resolve on the same rules, and one platform calling a recession two straight quarters of falling GDP while the other waits for an official declaration can keep them apart for months. The gap has to beat both fees and the money you’ll have locked up. Both orders have to fill at those prices, and a gap that moves 5 cents while you log in to the second platform is mostly gone. And both platforms have to be open to you: from the US, polymarket.com is close-only, so US readers run the same comparison between Kalshi and Polymarket US.
The money in a pair sits on both platforms until the market resolves, so I size these with cash I have no other use for. The word match in markets_match is deliberately simple and catches the obvious pairs, and each one it finds still gets a human read of both resolution sections.
Now go build something this weekend!
John Cook