Polymarket and Kalshi list a lot of the same questions: Fed rate decisions, CPI prints, elections, NFL and NBA games, “Will Bitcoin close above $X”. They are separate order books with separate crowds, so the same event regularly trades at different prices on each.
When the gap is wide enough, you can buy YES on one platform and NO on the other for less than $1 total. One of the two contracts pays $1 no matter what happens. The difference is yours, minus fees.
Arbitrage in prediction markets is well documented. A 2025 academic study of Polymarket on-chain data (“Unravelling the Probabilistic Forest”, arXiv 2508.03474) estimated around $40 million in realized arbitrage profit on Polymarket for markets resolved between April 2024 and April 2025. Note what that study measured: mispricings inside Polymarket (YES + NO of one market, or related markets, not summing to $1), not Polymarket-vs-Kalshi gaps. It shows bots actively harvest these edges. It does not tell you how much the cross-platform version pays.
This guide gives you a Python scanner that pulls both platforms’ public APIs, matches the same markets, computes the edge after both platforms’ fees, and checks order book depth so you know how much you can actually fill. Then the honest part: how much capital it takes, why the numbers are smaller than YouTube says, and the one risk that wipes out beginners (resolution mismatch).
Who this works for (read first)
- US residents: Kalshi (CFTC-regulated exchange) plus Polymarket US (the CFTC-regulated exchange Polymarket built on QCEX, which it bought for $112 million in July 2025; the iOS app dropped its waitlist in May 2026). Both legal, both KYC. Polymarket US is a separate exchange with its own order book, prices and fee schedule. The scanner below reads international Polymarket’s public API, so a US reader has to confirm every gap in the Polymarket US app before trading. Do not use a VPN to reach international polymarket.com from the US: it is against Polymarket’s terms, and the account and funds can be frozen.
- Outside the US: international Polymarket is blocked or close-only in a long list of countries, including the UK, France, Germany, Italy, Belgium, Poland, Australia, Brazil, Singapore, Thailand, Taiwan and several Canadian provinces (the full list is on Polymarket’s geoblock docs page). Kalshi opened to traders in 140+ countries in October 2025, with its own exclusions. Check both platforms’ country lists for your own country before depositing a cent.
- Indonesia: treat this the same as betting. Komdigi blocked Polymarket on 22 May 2026, calling it online gambling disguised as a prediction market. Online gambling falls under Article 27(2) of the ITE Law, and the new KUHP (Articles 426 and 427, in force since 2 January 2026) also covers gambling. Reading the code is fine. Trading from here is a legal risk, and a VPN does not change that.
If you cannot legally hold an account on both platforms, you cannot do this. One-sided “arbitrage” is just betting.
How the money works
Each contract pays $1 if its outcome happens and $0 if not. Prices are in cents.
Say the market is “Will the Fed cut rates at the December meeting?”
- Kalshi: YES ask $0.58
- Polymarket: YES bid $0.63, so NO ask is about $0.37 (YES and NO on Polymarket are mirror tokens, NO price ≈ 1 − YES price)
Buy 100 Kalshi YES at $0.58 = $58.00. Buy 100 Polymarket NO at $0.37 = $37.00. Total $95.00.
- Fed cuts: Kalshi pays $100, Polymarket NO pays $0. You get $100.
- Fed holds: Kalshi pays $0, Polymarket NO pays $100. You get $100.
Gross profit $5.00. Now fees.
Kalshi’s taker fee is 0.07 × contracts × price × (1 − price), rounded up to the next cent. For 100 contracts at $0.58: 0.07 × 100 × 0.58 × 0.42 = $1.7052, rounded up to $1.71. It peaks at 1.75¢ per contract at a 50¢ price and shrinks toward the edges. Resting (maker) orders are free on most series; a few series charge a smaller maker fee.
Polymarket is no longer fee-free. Since its fee overhaul on 30 March 2026, takers pay contracts × rate × price × (1 − price), with the rate set by category: 0.04 for politics, finance and tech, 0.05 for sports, economics, culture and weather, 0.07 for crypto, and 0 for geopolitics. Some individual markets are still listed at zero. Makers pay nothing. The Gamma API returns each market’s rate in a feeSchedule field, and the scanner below reads it rather than guessing.
Say this Fed market is charged at 0.05: 0.05 × 100 × 0.37 × 0.63 = $1.17.
Net: $100 − $95 − $1.71 − $1.17 = $2.12 on $97.88 deployed, about 2.2%. If the meeting is 40 days away, that is roughly 20% annualized, with no exposure to whether the Fed cuts.
That is the best case. A 5¢ gross gap in a liquid market is rare. Most real gaps net 1 to 3 cents after both fees, many close within minutes, and plenty are only a few hundred dollars deep.
What you need
| Item | Cost | Notes |
|---|---|---|
| Kalshi account | Free | KYC. Deposit by bank transfer (ACH), debit card or crypto |
| Polymarket account | Free | International: USDC on Polygon, gas is fractions of a cent. US: the Polymarket US app (iOS), card/bank funding |
| Bankroll | $2,000 minimum, $10,000+ to matter | Split across both platforms. You cannot move money fast between them, so each side must be pre-funded |
| Python 3.10+ | Free | pip install requests rapidfuzz |
| VPS (optional) | ~$4 to $7/month | Hetzner, DigitalOcean or Vultr basic tier, to run the scanner every few minutes |
| Telegram bot (optional) | Free | For alerts when an edge appears |
| Spreadsheet | Free | Every pair, both legs, costs, resolution date. Taxes need it |
Both platforms’ market data APIs are public. Scanning needs no API keys. You only need keys if you automate order placement later.
Step 1: Pull Kalshi markets
Kalshi’s REST API lives at https://api.elections.kalshi.com/trade-api/v2 (the “elections” name is historical; it serves every market). Quick test:
curl -s "https://api.elections.kalshi.com/trade-api/v2/markets?status=open&limit=5" \
| python3 -m json.tool | head -40
You will see ticker, title, yes_sub_title, yes_ask_dollars, no_ask_dollars, expected_expiration_time and volume fields. Prices are dollar strings like "0.5800"; Kalshi retired the old integer-cent fields.
The flat /markets list is dominated by multi-leg sports parlays (tickers starting with KXMVE) and player props, and there are well over 100,000 open markets. Paging through all of them is slow, and fuzzy-matching against them is slower. The /events endpoint is the better route: around 15,000 open events, each with a human title (“Next French presidential election winner?”) and its markets nested inside, and no parlays. Test it:
curl -s "https://api.elections.kalshi.com/trade-api/v2/events?status=open&limit=3&with_nested_markets=true" \
| python3 -m json.tool | head -60
Step 2: Pull Polymarket markets
Polymarket’s Gamma API returns market metadata plus best bid and ask:
curl -s "https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=3&order=volume24hr&ascending=false" \
| python3 -m json.tool | head -60
Useful fields: question, slug, outcomes (a JSON string like ["Yes", "No"]), bestBid and bestAsk for the YES token, endDate, clobTokenIds (YES and NO token IDs, needed for the order book), groupItemTitle (the outcome name inside a multi-outcome event, e.g. “Marine Le Pen”), events[0].title (the parent event, e.g. “Next French Presidential Election”) and feeSchedule.rate (the taker fee rate).
The scanner matches the way a human would: event title to event title first, then the outcome name to Kalshi’s yes_sub_title inside that event.
Step 3: The scanner
Save as scan.py:
import json
import math
from collections import defaultdict
from datetime import datetime, timezone
import requests
from rapidfuzz import fuzz, process, utils
KALSHI = "https://api.elections.kalshi.com/trade-api/v2"
GAMMA = "https://gamma-api.polymarket.com/markets"
MIN_EDGE = 0.015 # dollars per $1 pair, after both fees
EVENT_MATCH = 82 # fuzzy score 0-100 for event titles
OUTCOME_MATCH = 85 # fuzzy score 0-100 for outcome names inside the event
def kalshi_fee(price, n=100):
# 0.07 x C x P x (1-P), rounded up to the cent (round() strips float noise first)
return math.ceil(round(0.07 * n * price * (1 - price) * 100, 6)) / 100 / n
def poly_fee(m, price):
rate = (m.get("feeSchedule") or {}).get("rate") or 0 if m.get("feesEnabled") else 0
return rate * price * (1 - price)
def kprice(m, key):
p = m.get(key + "_dollars")
if p in (None, ""):
return None
p = float(p)
return p if 0 < p < 1 else None
def days_left(iso):
if not iso:
return None
end = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return max((end - datetime.now(timezone.utc)).total_seconds() / 86400, 0.1)
def kalshi_events():
events, cursor = defaultdict(list), None
while True:
params = {"status": "open", "limit": 200, "with_nested_markets": "true"}
if cursor:
params["cursor"] = cursor
r = requests.get(KALSHI + "/events", params=params, timeout=60)
r.raise_for_status()
data = r.json()
for e in data["events"]:
events[e["title"]] += [m for m in e.get("markets") or []
if m.get("status") == "active"]
cursor = data.get("cursor")
if not cursor:
return {t: ms for t, ms in events.items() if ms}
def poly_markets(pages=20): # Gamma caps limit at 100 per request
out = []
for i in range(pages):
r = requests.get(GAMMA, params={
"active": "true", "closed": "false", "limit": 100, "offset": i * 100,
"order": "volume24hr", "ascending": "false",
}, timeout=30)
r.raise_for_status()
batch = r.json()
if not batch:
break
for m in batch:
if json.loads(m.get("outcomes") or "[]") != ["Yes", "No"]:
continue
if m.get("bestAsk") is None or m.get("bestBid") is None:
continue
out.append(m)
return out
def match(p, events, titles):
ptitle = (p.get("events") or [{}])[0].get("title") or p["question"]
hit = process.extractOne(ptitle, titles, scorer=fuzz.token_sort_ratio,
processor=utils.default_process, score_cutoff=EVENT_MATCH)
if not hit:
return None
markets = events[hit[0]]
if len(markets) == 1:
return markets[0], hit[0], hit[1], 100
name = p.get("groupItemTitle") or p["question"]
sub = process.extractOne(name, [m.get("yes_sub_title") or "" for m in markets],
scorer=fuzz.WRatio, processor=utils.default_process,
score_cutoff=OUTCOME_MATCH)
if not sub:
return None
return markets[sub[2]], hit[0], hit[1], sub[1]
def main():
events = kalshi_events()
pm = poly_markets()
print(f"kalshi: {len(events)} events, polymarket: {len(pm)} markets")
titles = list(events)
rows = []
for p in pm:
found = match(p, events, titles)
if not found:
continue
k, ktitle, escore, oscore = found
pdays = days_left(p.get("endDate"))
kdays = days_left(k.get("expected_expiration_time") or k.get("close_time"))
gap = abs(pdays - kdays) if pdays and kdays else 0
days = max(pdays or 0, kdays or 0) or 30
p_yes = float(p["bestAsk"])
p_no = 1 - float(p["bestBid"])
k_yes, k_no = kprice(k, "yes_ask"), kprice(k, "no_ask")
legs = []
if k_yes:
legs.append(("K-YES + P-NO", k_yes, p_no))
if k_no:
legs.append(("K-NO + P-YES", k_no, p_yes))
for label, kp, pp in legs:
e = 1 - kp - pp - kalshi_fee(kp) - poly_fee(p, pp)
if e >= MIN_EDGE:
rows.append((e, e / (kp + pp) * 365 / days * 100, label, days, gap,
escore, oscore, k, ktitle, p))
rows.sort(key=lambda r: r[0], reverse=True)
for e, apy, label, days, gap, escore, oscore, k, ktitle, p in rows[:30]:
warn = f" DATES DIFFER BY {gap:.0f}d" if gap > 3 else ""
print(f"{e*100:5.2f}c {apy:6.1f}% APY {days:6.1f}d {label} "
f"match {escore:.0f}/{oscore:.0f}{warn}")
print(f" poly: {p['question'][:80]}")
print(f" kalshi: {ktitle[:55]} -> {k.get('yes_sub_title', '')[:30]}")
print(f" python3 depth.py {k['ticker']} {p['slug']} "
f"{'yes' if label.startswith('K-YES') else 'no'}")
if __name__ == "__main__":
main()
Run it:
pip install requests rapidfuzz
python3 scan.py
A run takes about two minutes, mostly spent paging through Kalshi’s ~15,000 events. Every row is a candidate, not a trade. When we ran it on 21 October 2026, the top “edges” were mostly mismatches: a Kalshi “FK Jablonec wins 1-0” correct-score market paired with Polymarket’s “Will FK Jablonec win?”, Bulgarian election margin buckets that didn’t line up (Polymarket “20–30%” vs Kalshi “≥20%”), and a “before 2027” market paired with one that has no deadline. The DATES DIFFER flag catches some of these. Step 5 is where you throw out the rest.
The genuine pairs it found (Grammys Album of the Year, the 2028 Republican nomination, the 2027 French election) showed net edges of 2 to 4 cents, on markets that resolve 4 to 25 months out. That works out to roughly 1 to 14% annualized. That is the typical picture, not the exception.
Step 4: Check depth before you trust the edge
Best bid and ask tell you the price for the first few contracts. The question is how many you can buy before the edge disappears. Save as depth.py:
import json
import sys
import requests
KALSHI = "https://api.elections.kalshi.com/trade-api/v2"
MIN_EDGE = 0.015
def kalshi_asks(ticker, side):
data = requests.get(f"{KALSHI}/markets/{ticker}/orderbook", timeout=20).json()
ob = data.get("orderbook_fp") or {}
other = "no" if side == "yes" else "yes"
levels = [(float(p), float(q)) for p, q in (ob.get(other + "_dollars") or [])]
# Kalshi books only show bids. A NO bid at p is a YES ask at 1 - p.
return sorted((round(1 - p, 4), q) for p, q in levels)
def poly_asks(slug, side):
m = requests.get("https://gamma-api.polymarket.com/markets",
params={"slug": slug}, timeout=20).json()[0]
rate = (m.get("feeSchedule") or {}).get("rate") or 0 if m.get("feesEnabled") else 0
yes_id, no_id = json.loads(m["clobTokenIds"])
book = requests.get("https://clob.polymarket.com/book",
params={"token_id": yes_id if side == "yes" else no_id},
timeout=20).json()
return sorted((float(a["price"]), float(a["size"])) for a in book["asks"]), rate
def walk(k, p, rate):
k, p = [list(x) for x in k], [list(x) for x in p]
i = j = 0
n = cost = fees = 0.0
while i < len(k) and j < len(p):
pk, pp = k[i][0], p[j][0]
fee = 0.07 * pk * (1 - pk) + rate * pp * (1 - pp)
if 1 - pk - pp - fee < MIN_EDGE:
break
q = min(k[i][1], p[j][1])
n, cost, fees = n + q, cost + q * (pk + pp), fees + q * fee
k[i][1] -= q
p[j][1] -= q
i += k[i][1] == 0
j += p[j][1] == 0
return n, cost, fees
if __name__ == "__main__":
ticker, slug, kside = sys.argv[1], sys.argv[2], sys.argv[3] # kside: yes or no
pside = "no" if kside == "yes" else "yes"
n, cost, fees = walk(kalshi_asks(ticker, kside), *poly_asks(slug, pside))
if not n:
print("no fillable size at this edge")
else:
print(f"fillable pairs: {n:.0f} cost ${cost:,.2f} fees ~${fees:,.2f} "
f"locked profit ${n - cost - fees:,.2f}")
python3 depth.py KXPRESNOMR-28-MR will-marco-rubio-win-the-2028-republican-presidential-nomination no
(Use the depth.py line the scanner prints; the one above is one of its real hits.) Real results from the same 21 October run: Grammys AOTY had 87 fillable pairs for $3.38 locked; the Rubio 2028 nomination had 480 pairs for $10.88; the Le Pen 2027 market had 23,000 pairs for $687 on $22,300, locked for about six months, or roughly 6% annualized, and only if the two rulebooks treat her eligibility the same way. You are looking for hundreds of pairs with at least $10 to $20 locked, on a timescale that justifies tying up the money.
Step 5: Read both rulebooks (this is the actual job)
Every Kalshi market has a “Rules” section with the exact resolution source. Every Polymarket market has a description and resolves via the UMA optimistic oracle. Before you buy anything, check line by line:
- Same deadline and time zone. “By December 31” at 11:59 PM ET vs. 12:00 PM UTC is a different market.
- Same source. One uses the BLS headline number, the other uses a specific news outlet’s call. One counts the first release, the other counts revisions.
- Same edge cases. Game postponed? Candidate withdraws? Event “announced” vs. “happened”? Kalshi’s rules often settle these differently from Polymarket’s.
- Oracle risk on Polymarket. UMA resolutions can be disputed and have gone against the obvious reading. The March 2025 Ukraine minerals deal market resolved YES despite no deal being signed, after large UMA token holders voted, and Polymarket declined refunds.
If the rules differ in any way that could make both legs lose, it is not an arbitrage. Skip it, however wide the gap. This is also why the gap exists: bots skip markets they cannot verify automatically, and the wide, persistent gaps are often wide because the rules differ.
You can paste both rule texts into Claude or Gemini and ask “list every situation where these two resolve differently”. It is a good first pass. It is not a substitute for reading them yourself.
Step 6: Execute without getting legged
You place two orders on two platforms. Between them, the price can move. That is “leg risk”.
- Fill the thin side first. Whichever book has less depth, buy there first, then immediately buy the other side.
- Use limit orders at the price you checked, never market orders on thin books.
- If the second leg moved, either take the worse price (smaller edge) or sell the first leg right away. Do not leave one side open hoping the price comes back. That is a naked bet.
- Size to the smaller book. Leftover unhedged contracts are bets.
Run the scanner from cron every 5 minutes on a VPS and send hits to Telegram. Automating order placement (Kalshi’s API uses RSA-signed keys; Polymarket has the official py-clob-client library) is the eventual step, but do it manually for at least a month so you learn where things go wrong while the amounts are small.
Honest cost breakdown
| Item | Monthly |
|---|---|
| VPS | $0 to $6 |
| APIs | $0 (public market data) |
| Trading fees | Kalshi up to 1.75¢ and Polymarket up to ~1.75¢ per contract taken (category-dependent), already inside the edge math |
| Funding/withdrawal | Small: card deposits and some withdrawal methods have fees, Polygon gas is fractions of a cent |
| Bankroll | $2,000 to $50,000, locked until resolution |
| Cash cost | under $10/month |
The real cost is capital lock-up. Money in a pair that resolves in 60 days earns nothing else for 60 days.
Realistic earnings
Run the numbers before you get excited.
- $10,000 bankroll, long-dated markets (politics, economics, elections): net edges after both fees are typically 2 to 4%, but many of these markets resolve months or years out, and you can rarely keep more than 60 to 70% of capital deployed because each side must already be funded on the right platform. Expect something like $20 to $150/month equivalent, paid out only when markets resolve. That is savings-account territory, with more risk. It is not a salary.
- $10,000 bankroll, short-dated markets (sports games, daily crypto and economic prints that settle within 1 to 3 days): capital turns over fast, but gaps are smaller, close in seconds, and you compete with bots. There are no reliable public numbers for what people earn here. Since Polymarket added sports taker fees (5%) in 2026, many short-dated gaps no longer survive both fees. Treat this tier as unproven until your own log says otherwise.
- Bigger numbers need six-figure bankrolls spread across both platforms, automated execution, and a rules-checking routine for new markets the day they list (gaps tend to be widest at launch). That is the territory of a small number of fast, well-capitalised operators, and they do not publish their returns.
Bonus angle: Kalshi lists sports game markets and is available in most US states, which makes it a usable hedge for US sportsbook sign-up bonuses (the same idea as matched betting). Back the bonus bet at the sportsbook, buy the opposite outcome on Kalshi. Several states (Nevada among them) are fighting Kalshi’s sports contracts in court, and access can change, so check yours.
Risks: freezes, rule mismatches and taxes
Resolution mismatch. The big one, covered in Step 5. One badly-read market can lose 100% of both legs, wiping out months of 2% wins.
Oracle disputes. Polymarket resolutions can be challenged, which delays payout by days, and occasionally resolve against the plain reading. Kalshi markets can also settle late or be amended, and Kalshi’s rules let it adjust settlement in some edge cases.
Account and geo issues. Accessing a platform from a country it blocks, or through a VPN to hide your location, breaks its terms. The consequence is a frozen account, sometimes with your balance inside. One account per person on each platform, in your own name.
Regulatory changes. US state regulators have sent cease-and-desist letters over sports event contracts, and several countries have blocked Polymarket in the last two years. A platform becoming unavailable where you live while your capital is locked in positions is a real scenario.
Counterparty and crypto risk. International Polymarket holds your USDC in a crypto wallet. Lose the keys or approve a malicious contract and it is gone. Never sign transactions from links in Discord or Telegram “support” messages.
Taxes. Gains are taxable income in most countries, and you have two platforms worth of trades to reconcile, one of them on-chain. Keep the spreadsheet from day one and talk to an accountant about how your country treats event contracts.
Reality check: what does not work
- Trusting the fuzzy match. Most scanner hits are different markets with similar titles. If you skip Step 5, the scanner is a machine for losing money.
- Ignoring the fee curve. A 1.5¢ gap at a 50¢ price is wiped out by Kalshi’s 1.75¢ fee before Polymarket’s fee is even counted. Gaps near 10¢ or 90¢ survive fees much better, because both platforms’ fees shrink toward the edges.
- Chasing APY on tiny size. “400% APY” on a 1-day pair with $30 of depth is $0.33.
- Single-platform bankroll. You find a gap, but your money is all on Polymarket and Kalshi needs a funded account. By the time an ACH deposit clears, the gap is gone.
- Selling early by panic. The pair pays $1 at resolution. If both prices move against you mid-way, that is noise. You are only exposed if the rules differ.
- Copying “Polymarket bot makes $X” threads. Many are screenshots of directional bets or wallets that got lucky. Pure arbitrage profits are smaller and steadier, and the operators do not post their edge.
Start today
- Confirm you can legally hold accounts on both Kalshi and Polymarket (or Polymarket US) where you live.
- Run
scan.py. No account, no money, no keys. Look at what it finds for a week. - Pick the five best candidates, read both rulebooks, and run
depth.pyon the ones that survive. - Fund both platforms with a small amount ($500 each) and execute one pair by hand.
- Log it, wait for resolution, confirm the $1 payout. Then scale size, not risk.
The scanner is free and takes five minutes to set up. Your first resolved pair tells you whether the gap was real or whether you missed something in the rules.