The three arbitrage edges that are real

Polymarket prices binary and categorical events. Three pricing dislocations occur often enough to be worth coding against:

  1. YES + NO < $1.00 on a binary market: buy both, and if both fills hold the combined position settles toward $1.00 before fees and execution risk.
  2. Sum of multi-outcome prices < $1.00 across mutually-exclusive outcomes (e.g. an election with 5 candidates).
  3. Cross-venue spread: same event priced differently on Polymarket vs Kalshi vs Manifold. Buy the cheap side, hedge on the rich side, hold to settlement.

Each of these has the same shape: a deterministic payoff at expiry if both legs fill and resolution is clean, gated by execution costs. In practice none of it is guaranteed. Each also has a population of bots already watching the headline markets, so where you find edge is in the long tail.

The math, in one box

For a binary market quoted in YES/NO probabilities p_yes and p_no, with taker fee f and slippage s at your size:

edge_per_share = 1.00 - p_yes - p_no - 2*f - s
expected_pnl   = edge_per_share * shares
condition      = edge_per_share > 0

For a categorical market with n outcomes:

edge_per_share = 1.00 - sum(p_i for i in outcomes) - n*f - s
condition      = edge_per_share > 0

Read this twice. The constant is 1.00, not 1. If the sum of probabilities you can buy ever lands below 1.00 net of fees and slippage, you have a locked-in spread at expiry, subject to resolution risk (see below). The whole bot exists to detect that condition and execute fast enough that it survives the 200ms it takes for everyone else to notice.

The minimum viable bot in 80 lines

We build directly on py-clob-client. No agent layer needed for pure arbitrage.

import time
from py_clob_client.client import ClobClient
from py_clob_client.clob_types import OrderArgs, OrderType

FEE = 0.02      # taker, both sides combined
THRESHOLD = 0.005

client = ClobClient(host="https://clob.polymarket.com", chain_id=137, key=PRIVATE_KEY)
client.set_api_creds(client.create_or_derive_api_creds())

def best(side, book):
    levels = book.asks if side == "YES" else book.bids
    return float(levels[0].price), float(levels[0].size)

def scan(market):
    yes_book = client.get_order_book(token_id=market["yes"])
    no_book  = client.get_order_book(token_id=market["no"])
    p_yes, sz_yes = best("YES", yes_book)
    p_no,  sz_no  = best("YES", no_book)        # buying NO == taking the ask
    edge = 1.0 - p_yes - p_no - FEE
    size = min(sz_yes, sz_no)
    return edge, size, p_yes, p_no

def fire(market, p_yes, p_no, size):
    for token_id, price in [(market["yes"], p_yes), (market["no"], p_no)]:
        client.create_and_post_order(OrderArgs(
            price=price, size=size, side="BUY", token_id=token_id,
        ), OrderType.FOK)   # fill-or-kill: no half-fills

while True:
    for m in MARKETS:
        edge, size, p_yes, p_no = scan(m)
        if edge > THRESHOLD and size >= 10:
            fire(m, p_yes, p_no, min(size, MAX_SHARES))
    time.sleep(0.5)

Run that against a list of 50 long-tail markets and you have a working bot. Run it against the top 10 and you will lose money on the gas alone: those are saturated.

Where the edge actually goes to die

The math above is the sales pitch. Production reality is the rest of this section.

  • Gas. Each order is a Polygon transaction. Two-leg arb = two transactions per round-trip = roughly $0.04–$0.20 in gas at 2026 fee levels. Your edge has to clear that floor at every entry and exit.
  • Slippage on the second leg. Between filling YES and posting NO, the NO book moves. The standard fix is to use FOK (fill-or-kill) on both legs and accept that 30–60% of detected opportunities will not execute. The remaining 40–70% are real.
  • Adverse selection. When a long-tail market shows YES + NO < 1.00, ask why. Often it is because resolution is ambiguous and the smart money is letting you take the wrong side. Read the market description before posting. A bot that does this with an LLM (the only thing LLMs are clearly good at on prediction markets) will outperform one that does not.
  • Mempool latency. Polygon block times are ~2 seconds. A faster bot has already eaten the spread before your transaction lands. Run a private RPC and a high-priority gas tip if you are running real capital.
  • Resolution risk. Polymarket markets resolve via UMA's optimistic oracle. Disputes happen. A "won" arbitrage can be re-resolved against you if the oracle decision is contested. Keep a buffer in your sizing.

From script to production

The 80-line bot above is the demo. The production checklist is the work:

  • Persist every signal, decision, and fill to disk. You will need it when something breaks.
  • Cap exposure per market and per outcome. A single misread market description can sink a quarter of edge.
  • Monitor your bot's PnL per leg, not just total. Half-filled trades hide as flat PnL while accumulating directional exposure.
  • Pre-compute the universe of markets nightly. Filter for liquidity > $5k, expiry > 24h, and resolved-market provenance from a trusted oracle source.
  • Have a kill switch. RPC outages and oracle disputes both look exactly like "the bot is making money" until they don't.

When to graduate to an agent runtime

Pure arbitrage is reactive: you need a fast loop, not a smart loop. The day you start asking "which markets are about to mispice?" is the day arbitrage becomes inseparable from prediction. Predicting which Fed-decision markets will dislocate at 2:00pm Wednesday means reading the FOMC minutes, the dot-plot, and Powell's prepared remarks. That is the LLM job.

At that point your bot is no longer an arbitrage bot: it is a news-reaction bot with arbitrage execution. Building it cleanly in py-clob-client requires re-implementing news ingestion, model calls, retries, and audit trail. Building it on an agent OS like NickAI means writing a 4-step graph and calling it done.

Both are valid. Pick by how much engineering bandwidth you have left after the actual strategy.