Four ways prediction-market "arbitrage" numbers lie

작성자

카테고리:

← 피드로
DEV Community · Anton · 2026-07-30 개발(SW)

Anton

I spent a few months capturing and auditing order-book and oracle data from prediction markets (Polymarket, Kalshi) for my own research. I went in wanting to find cross-venue arbitrage. What I mostly found was that my own numbers were wrong, four times, for four boring reasons.

Every one of those mistakes produces a confident, impressive, completely useless result. If you are building anything against these venues, here they are, with the fixes.

1. The order book is not sorted the way you assume

Polymarket’s CLOB returns asks in descending price order. If you read asks[0] expecting the best price, you get the worst one in the book.

# wrong: reads the most expensive ask in the book
best_ask = float(book["asks"][0]["price"])

# right
best_ask = min(float(a["price"]) for a in book["asks"])
best_bid = max(float(b["price"]) for b in book["bids"])

Enter fullscreen mode Exit fullscreen mode

This one is nasty because it fails silently and in a plausible direction. In one of my own scanners it made a market look like it had no liquidity at all (best ask read as 0.99), which made an arbitrage detector report zero opportunities for days. Not zero because the market was efficient. Zero because of a sort order.

2. Top of book is not a price you can trade

Almost every “spread” screenshot compares two best prices. The best price typically covers a handful of shares. If you intend to put $100 on a leg, the number you care about is the average price after walking the ladder:

def exec_price(levels, stake_usd):
    """levels: [(price, size), ...] ascending by price."""
    spent = shares = 0.0
    for price, size in levels:
        take = min(price * size, stake_usd - spent)
        if take <= 0:
            break
        shares += take / price
        spent += take
    return (spent / shares) if shares else None

Enter fullscreen mode Exit fullscreen mode

On thin markets the difference between top-of-book and a $100 fill is routinely larger than the entire claimed edge.

3. Fees exist, and they are not flat

This is the one that surprised me most, because plenty of public tools still assume zero.

Both venues charge takers a fee shaped like a variance term:

fee = shares × rate × p × (1 - p)

Enter fullscreen mode Exit fullscreen mode

where p is the price you pay. Polymarket’s rate depends on the market category, and you can read it per market from the API (feesEnabled, feeType, feeSchedule). Crypto markets carry the highest rate, sports sit lower, finance and politics lower still. Kalshi uses the same shape and rounds up to the next cent per fill.

Two consequences:

  • The fee peaks at p = 0.5. On a coin-flip market it is at its worst, which is exactly where “the spread looks like 2 cents” arguments live.
  • Makers are not charged and receive a rebate. So the same strategy can be negative as a taker and positive as a maker. If your backtest crosses the spread, it is testing the wrong execution mode.

A 1% gross spread on a 50/50 market can be entirely consumed by fees on both legs. I had several “opportunities” that were solidly negative once I priced them honestly.

4. Mid prices hallucinate arbitrage

This was the most instructive one. I built a scanner for model-free arbitrage inside a single event: logical constraints that must hold or there is free money. For example, on the same game, P(team wins by 2+) <= P(team wins), over/under ladders must be monotone in the line, and for a two-outcome market ask(YES) + ask(NO) < 1 is a direct buy-both arb.

To avoid hand-coding each rule, I mapped every tradable token to the set of game outcomes on which it pays $1, over an enumerated state space, and stored those payoff sets as bitmasks. Then one condition subsumes all of the rules above: an arb exists if and only if a set of tokens whose payoff masks union to the full state space can be bought for under $1.

Across a full sweep of open single-game markets, roughly 20,000 live order-book pulls over 18 rounds, the results were:

  • On mid prices: about 5% of constraints appeared violated.
  • On real asks: every single one of them died. Executable profit: exactly zero.

That 5% is the width of the spread, nothing else. Any screener that computes on mids, or on cached “best bid / best ask” summary fields from a list endpoint rather than the live book, will produce a steady stream of phantom arbitrage.

I also validated the engine by injecting a fabricated violation into a real snapshot and confirming it got flagged and depth-walked correctly. So the zero was a real zero, not a broken pipeline. Worth doing: a scanner that finds nothing and a scanner that is broken look identical from the outside.

The structural reason there was nothing to find, by the way, is worth knowing: a single market maker quotes both sides of every binary one tick apart and prices the whole ladder off one internally consistent model. Thin does not imply inconsistent.

Bonus: cross-venue matching is where the real bugs hide

If you compare two venues, the hard part is not the math, it is deciding that two markets are the same market. Fuzzy string similarity alone will happily pair:

  • “will X be on the ballot” with “will X pass” (different events entirely)
  • “rate increase” with “rate cut” (opposite bets)
  • deadline ladders that differ by a month
  • numeric strikes that differ by a factor of ten

Every one of those looks like a huge arbitrage, because it is a directional bet wearing an arbitrage costume. Fuzzy scoring belongs behind hard gates: resolution-date window, numeric-strike overlap, month consistency, direction antonyms, and event stage. And ship a confidence score with every row, because a 20% edge on an imperfect match is almost always a wrong pairing rather than free money.

A useful heuristic I now apply: the bigger the edge, the more likely it is my bug.

Takeaway

None of the four fixes is clever. They are all just refusing to accept a convenient number. But together they are the difference between a scanner that prints exciting rows and one whose rows you would actually put money on.

If you want the packaged versions rather than the lessons, I put two of these on the Apify Store: a Polymarket and Kalshi arbitrage finder that applies both venues’ fee schedules and walks real depth at your stake, and a Telegram channel monitor I built for watching signal channels without an API key. Both read public endpoints only.

Happy to compare notes if you are working on the same venues, especially on the matching problem, which I do not consider solved.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다