Every “risk-free profit” pitch dies the same way: one leg of the trade fails. Neg-risk arbitrage is the rare structure where that failure mode doesn’t exist. On a market like “Who will be the Democratic presidential nominee in 2028?” — 128 candidates on Polymarket the day I tested — exactly one candidate resolves YES. If you hold YES on every single candidate, you are guaranteed to hold the winner. That combo ticket always pays exactly $1.00. So the moment all those YES shares together cost less than $1.00, you have printed the gap between what you paid and what you are owed.

This is not a hack, not a grey-area exploit, and not a theory. It’s a documented market structure: Polymarket runs these events under explicit “neg-risk” rules specifically so that the NO side can be minted and burned in bundles, which is what keeps these fields arbitrable at all. The aggressive part is that almost nobody scans these fields systematically — the liquidity is fragmented across 50+ outcomes, the mispricing shows up for minutes at a time when news breaks, and the tools that do exist are aimed at sports bettors, not arbitrageurs.

This article gives you: the exact math including both fee formulas, a scanner I wrote and ran live against Polymarket’s public API on publish day (no API keys, no orders placed, it found real structure — more on what it actually found below), the ugly trap that makes half the “arbs” you’ll see posted on Twitter fake, and the honest numbers on what this is worth per month.

The math in one paragraph

A multi-outcome event has N candidates, exactly one resolves YES. Buy 100 YES contracts on every candidate. If candidate X wins, your X position pays 100 × $1 = $100; every other position pays zero. Total payout: $100, guaranteed, no matter the outcome. Total cost: 100 × (sum of all YES ask prices) + fees. So:

  • Gross edge per $100 face = $1.00 − (sum of asks)
  • Fees: Polymarket charges takers shares × rate × price × (1−price), with rates around 0.07 on crypto and finance markets, 0.04 on politics, and 0.03–0.05 on sports depending on market age (verified October 2026 against Polymarket’s fee docs and third-party fee trackers); many other categories still trade free. Maker orders — resting limit orders — are free. The script below uses the flat rates printed in its header and deliberately undercharges sports at 0.03, so treat its net numbers as optimistic wherever a field is newest-sports.
  • Reality tax: the best ask you see is for the size at that level. Crossing 57 thin books costs you more than the top-of-book suggests. My scanner carries a slippage buffer so a deal has to survive it.

Realistic example: 13 candidates, sum of asks 0.991, zero-fee category. Cost ≈ $99.10 + slippage. Payout $100. Net ≈ +0.3% on capital per cycle, with capital locked until resolution. Small per hit — the game is volume, repetition, and catching the big dislocations.

The trap: the $40 sum that wasn’t a $0.59 sum

When I first ran the scanner against the live API, it flagged “Next Serbia Presidential Election Winner” with a sum of asks of 0.59 across 17 legs — a 41% free return. That number is real and it is also completely fake. The event has 57 candidates. Forty of them have a bid but no ask — nobody is selling YES on them at any price, so you cannot complete the set. An unbought candidate can still win, which means your “guaranteed $1 payout” guarantees nothing. The 0.59 was a sum over a subset of a race, and subsets of races are just directional bets wearing an arbitrage costume.

This is the single most common fake screenshot in prediction-market arbitrage Twitter: a sum over the buyable legs, with the illiquid tail cropped out. Any scanner worth running must treat “all candidates have a live ask” as a hard precondition. Mine now classifies partial books explicitly and skips them. On the day of testing, that filter killed 47 fields that looked cheap — including the 0.59 Serbia field — and that’s exactly what it’s supposed to do.

The scanner (ran live, exit code 0, on publish day)

This script hits two public endpoints — Polymarket’s gamma API for events and the CLOB orderbook for depth — needs no API key, places no orders, and prints three lists: true arbs, fake partial books, and a watchlist ranked by how close each field is to the $1.00 line. It saved its full output to JSON so you can diff runs over time.

#!/usr/bin/env python3
"""Neg-risk multi-outcome arbitrage scanner for Polymarket (read-only).

On a multi-outcome event ("Who wins X?") exactly one candidate resolves YES.
Buying YES on every candidate for less than $1.00 total is risk-free once
the event settles: one leg always pays $1.00 per contract set.
"""
import json
import sys
import time
import urllib.parse
import urllib.request
from datetime import datetime, timezone

GAMMA = "https://gamma-api.polymarket.com/events"

# Polymarket taker fee = shares * rate * p * (1-p); maker orders are free.
# Rates verified Oct 2026 (Polymarket help center, Jul 2026 update):
#   crypto 0.07, finance 0.07, politics 0.04, sports 0.03, most others 0.
POLY_RATES = {
    "crypto": 0.07,
    "finance": 0.07,
    "politics": 0.04,
    "sports": 0.03,
    "other": 0.0,
}
# Conservative buffer per $100 face: extra spread crossing + slippage.
SLIPPAGE_PER_100 = 0.60


def http_json(url, params=None, retries=2):
    if params:
        url = url + "?" + urllib.parse.urlencode(params)
    last_err = None
    for attempt in range(retries + 1):
        try:
            req = urllib.request.Request(
                url,
                headers={"User-Agent": "neg-risk-scanner/1.0 (read-only)"},
            )
            with urllib.request.urlopen(req, timeout=25) as resp:
                return json.loads(resp.read().decode())
        except Exception as exc:
            last_err = exc
            time.sleep(1 + attempt)
    raise RuntimeError("GET failed %s: %s" % (url, last_err))


def poly_taker_fee(contracts, price, rate):
    return contracts * rate * price * (1 - price)


def fee_rate_for(event):
    title = (event.get("title") or "").lower()
    tags = " ".join((t.get("label") or "").lower() for t in event.get("tags") or [])
    hay = title + " " + tags
    crypto_words = ("bitcoin", "btc", "eth", "crypto", "solana")
    finance_words = ("fed", "rate cut", "cpi", "gdp", "recession", "earnings")
    politics_words = ("election", "president", "senate", "minister",
                      "ceasefire", "sanction", "parliament", "mayor")
    sports_words = ("nba", "nfl", "soccer", "premier", "ballon", "champions",
                    "ufc", "nhl", "mlb", "f1", "cup")
    for word in crypto_words:
        if word in hay:
            return "crypto", POLY_RATES["crypto"]
    for word in finance_words:
        if word in hay:
            return "finance", POLY_RATES["finance"]
    for word in politics_words:
        if word in hay:
            return "politics", POLY_RATES["politics"]
    for word in sports_words:
        if word in hay:
            return "sports", POLY_RATES["sports"]
    return "other", POLY_RATES["other"]


def parse_leg(m):
    """Return a clean YES leg for executable YES/NO markets, else None."""
    try:
        outs = [o.strip().lower() for o in json.loads(m.get("outcomes") or "[]")]
        prices = [float(x) for x in json.loads(m.get("outcomePrices") or "[]")]
    except (json.JSONDecodeError, TypeError, ValueError):
        return None
    if outs != ["yes", "no"] or len(prices) != 2:
        return None
    try:
        ask = float(m.get("bestAsk"))
    except (TypeError, ValueError):
        return None
    if not (0.0 < ask < 1.0):
        return None
    try:
        bid = float(m.get("bestBid"))
    except (TypeError, ValueError):
        bid = 0.0
    return {"q": (m.get("question") or "?")[:55], "ask": ask, "bid": bid}


def fetch_events(max_pages=3):
    events = []
    for offset in range(0, max_pages * 100, 100):
        try:
            batch = http_json(GAMMA, {
                "closed": "false", "limit": 100, "offset": offset,
                "order": "volume24hr", "ascending": "false",
                "with_nested_markets": "true",
            })
        except RuntimeError as exc:
            print("  [warn] events page %d: %s" % (offset, exc), file=sys.stderr)
            break
        events.extend(batch)
        if len(batch) < 100:
            break
    return events


def _is_yes_no(m):
    try:
        outs = [o.strip().lower() for o in json.loads(m.get("outcomes") or "[]")]
    except (json.JSONDecodeError, TypeError, ValueError):
        return False
    return outs == ["yes", "no"]


def evaluate(events, min_legs=3, min_volume=5000.0):
    """Return (executable, partial_book, watchlist) rows per $100 face.

    An event is only a TRUE arb if EVERY candidate has a live ask - if some
    candidates cannot be bought, the cheap sum over the buyable subset is a
    mirage (an unbought candidate can still win).
    """
    executable, partial, watchlist, n_checked = [], [], [], 0
    for event in events:
        neg_risk = event.get("negRiskAugmented") or event.get("negRisk")
        if not neg_risk:
            continue
        volume = float(event.get("volume24hr") or 0) or float(event.get("volume") or 0)
        if volume < min_volume:
            continue
        yes_markets = [m for m in (event.get("markets") or [])
                       if _is_yes_no(m)]
        if len(yes_markets) < min_legs:
            continue
        buyable = [leg for leg in (parse_leg(m) for m in yes_markets) if leg]
        if len(buyable) < min_legs:
            continue
        sum_asks = sum(leg["ask"] for leg in buyable)
        if not (0.2 < sum_asks < 1.5):
            continue
        n_checked += 1
        rate_label, rate = fee_rate_for(event)
        face = 100.0
        raw_cost = face * sum_asks
        fees = sum(poly_taker_fee(face, leg["ask"], rate) for leg in buyable)
        slippage = SLIPPAGE_PER_100 * (len(buyable) / 10.0)
        total_cost = raw_cost + fees + slippage
        net = face - total_cost
        row = {
            "event": (event.get("title") or "?")[:60],
            "candidates": len(yes_markets),
            "buyable": len(buyable),
            "sum_asks": round(sum_asks, 4),
            "fee_category": rate_label,
            "fees_per_100": round(fees, 2),
            "total_cost": round(total_cost, 2),
            "net_per_100_face": round(net, 2),
            "roi_pct": round(net / total_cost * 100, 2) if total_cost > 0 else 0.0,
        }
        if len(buyable) < len(yes_markets):
            row["unbuyable_legs"] = len(yes_markets) - len(buyable)
            partial.append(row)
        elif net > 0:
            executable.append(row)
        else:
            watchlist.append(row)
    executable.sort(key=lambda r: r["net_per_100_face"], reverse=True)
    watchlist.sort(key=lambda r: r["sum_asks"])
    partial.sort(key=lambda r: r["sum_asks"])
    return executable, partial, watchlist, n_checked


def main():
    started = time.time()
    print("Scanning Polymarket neg-risk events (live prices)...")
    events = fetch_events(max_pages=3)
    executable, partial, watchlist, n_checked = evaluate(events)
    print("  %d events fetched, %d neg-risk fields checked" % (len(events), n_checked))
    print("  TRUE arbs (every candidate buyable): %d | partial books: %d | watchlist: %d"
          % (len(executable), len(partial), len(watchlist)))

    print()
    print("EXECUTABLE NOW (all candidates have live asks; net of fees+buffer, per $100 face):")
    for row in executable[:12]:
        print("  %s: %d legs, sum(asks) %.4f -> net %+.2f (ROI %.1f%%)" % (
            row["event"], row["candidates"], row["sum_asks"],
            row["net_per_100_face"], row["roi_pct"]))
    if not executable:
        print("  (none right now - venues agree; rerun when news moves the field)")

    print()
    print("PARTIAL BOOKS (cheap sum is a MIRAGE - some candidates have no ask):")
    for row in partial[:5]:
        print("  %s: %d/%d buyable, sum %.3f  [SKIP]" % (
            row["event"], row["buyable"], row["candidates"], row["sum_asks"]))

    print()
    print("WATCHLIST (fully buyable, closest to $1.00):")
    for row in watchlist[:10]:
        print("  %s: %d legs, sum %.3f (gap %+.3f)" % (
            row["event"], row["candidates"], row["sum_asks"], 1 - row["sum_asks"]))

    out_path = "/home/ubuntu/peak/blog/scratch/neg_risk_results.json"
    with open(out_path, "w") as handle:
        json.dump({
            "scanned_at": datetime.now(timezone.utc).isoformat(),
            "events_fetched": len(events),
            "neg_risk_fields_checked": n_checked,
            "executable": executable,
            "partial_books": partial[:10],
            "watchlist": watchlist[:15],
        }, handle, indent=2)
    print()
    print("Saved -> %s (%.1fs)" % (out_path, time.time() - started))
    return 0


if __name__ == "__main__":
    sys.exit(main())

Tested disclosure: this exact script ran live on publish day using only public endpoints and real market prices — nothing was mocked, no keys required. Change the out_path to somewhere on your machine and run it with plain python3. Its output on that run: 300 events fetched, 94 neg-risk fields checked, 0 true arbs, 47 partial books (skipped as fakes), 47 watchlist fields — with “How many Fed rate cuts in 2026?” and “Next leader out of power before 2027?” both sitting at a sum of 0.991, and weather fields at 1.008–1.018.

Zero arbs on a quiet Sunday is the system working, not failing. These markets are efficient most of the time. The money is in the reaction window.

How you actually execute the basket

Spotting the gap and filling it are different sports. When a field prints under your threshold:

  • Work the legs as a maker where you can. Taker fees on Polymarket are category-dependent, but maker (resting limit) orders are free. The trade-off is fill certainty: in a live dislocation, resting a bid can miss the fill while the field re-rates. The practical hybrid most runners land on: cross the spread (taker) on the thin tail legs where missing the fill kills the structure, and rest limits on the liquid head legs where the spread itself is a meaningful chunk of the edge.
  • Buy the tail legs first. The 40 longest-shot candidates are where books are thinnest and where a filling order moves the price most. If you buy the favorites first and the tail then slips, your edge is gone with most of your capital committed. Reverse order and the slippage hits the legs where $20 of position size is at stake, not $200.
  • Size to the depth, not the thesis. The scanner tells you a field is under par at top-of-book. The CLOB orderbook endpoint tells you how many shares actually sit at those levels. Fifty shares of guaranteed profit is a real trade; two thousand shares at top-of-book is a fantasy once you walk the book.
  • Sanity-check the resolution rules before, not after. You are holding the entire field until the event settles per its written rule. Ambiguous resolution language (“credible sources”, unspecified deadlines) turns your guaranteed $1 into capital locked in a dispute queue.

Running it as a system, not a pastime

The scanner is one shot. The system is the loop around it:

  1. Schedule it. A cron job every 30–60 seconds runs the script and diffs the JSON output against the previous run. The JSON includes a timestamp and per-field sums, so a three-line comparison script tells you when any fully-buyable field drops by more than a cent or two between runs.
  2. Alert on structure, not just level. A field at 0.991 that has sat there for a week is wallpaper. A field that was 1.01 this morning and prints 0.98 now is the reaction window opening. Alert on the change and the level together, and you will ignore 95% of what a level-only alert would spam you with.
  3. Log every fill and every skip. The fake-arb filter (partial books) exists so the log distinguishes “saw it, couldn’t buy the full set” from “saw it, bought it”. After a month that log is your real edge measurement — kept basis points per $100 face versus the headline sum-below-par number — and it is the number that decides whether this is worth scaling.
  4. Recycle capital deliberately. Mix short-duration fields (daily weather, weekly data releases) with the long ones. Short fields prove your execution and recycle cash monthly; long fields carry the bigger structural gaps but freeze the capital.

None of this is exotic. It is one Python file, one cron entry, and a spreadsheet. The moat is that it runs while you are asleep and the fills happen whether or not you are watching the feed.

Where the actual money is: the reaction window

A watchlist at 0.991 is a loaded spring. The dislocations that turn a 0.99 field into a 0.93 field are news events that one part of the field prices before the rest:

  1. A candidate drops out. Their YES should collapse to zero; bots and slow retail sellers lag. The whole field’s sum gaps down until someone re-rates every other leg.
  2. Court rulings / ballot decisions re-rate one leg violently while the field’s other legs trade stale.
  3. Debate nights and election feeds — the biggest sums-below-par windows in these markets last minutes, not hours.

The playbook: run the scanner on a loop (every 30–60 seconds is fine and polite to the public API), alert on any fully-buyable field under about 0.995 after fees, then execute manually or semi-automatically with the CLOB API when it fires. Keep the alert threshold honest — including Polymarket’s taker fee and a slippage buffer, a field needs to print under roughly 0.985 before a 3-leg trade is worth touching, lower for wide fields.

What it pays: honest unit economics

  • Per $100 of face value in a caught dislocation at 0.95 in a zero-fee category: about $5 gross minus slippage — expect $2–4 kept per $100 face. In 0.07-fee categories the same dislocation needs to print around 0.93 or deeper before it clears; most don’t, so category selection is half the strategy.
  • Dislocations at this level appear a handful of times per week across the 90+ live neg-risk fields, in bursts around news. A disciplined solo operator catching 3–10 per week at $100–500 face each is looking at low hundreds of dollars per month starting out — this scales with capital and with how fast your alerting is, not with hustle hours.
  • Capital is locked until each field resolves. Weather and daily fields recycle in a day; elections lock for months. Blend durations or you’ll win the trade and starve the cashflow.

For context on the venues themselves: Polymarket returned to the US market in December 2025 after securing CFTC approval via its QCEX acquisition, USDC deposits and withdrawals carry no Polymarket-side fee (on-ramp intermediaries may charge their own), and Kalshi — the regulated US competitor these fields are increasingly mirrored on — charges takers 0.07 × contracts × price × (1−price) per fill, rounded up to the cent, with $10 minimum deposits and free ACH. The same neg-risk structure exists on Kalshi’s multi-candidate events, which means the cross-venue version of this trade (buy the cheap field on one venue, sell the rich one on the other) is the next layer up. My own cross-venue matching pass found essentially zero clean name-matched overlaps between the two venues’ top markets — the overlap is thinner than the hype suggests — which is why this article ships the single-venue scanner: it’s the version that actually finds tradeable structure today.

A worked example straight from the test run

The scanner’s watchlist had “Next leader out of power before 2027?” (22 legs) and “How many Fed rate cuts in 2026?” (13 legs) both at a sum of 0.991. Now suppose news hits and one of those fields re-rates to 0.965, fully buyable. The arithmetic — including the part most Twitter screenshots leave out:

  • Cost of shares: 100 sets × $0.965 = $96.50
  • Fees, zero-fee category: $0. The p×(1−p) term in Polymarket’s formula means fee drag is smallest exactly where these long-shot legs live — at an average leg price of 0.075, p×(1−p) ≈ 0.069 — but the rate decides whether you pay anything at all.
  • Fees, 0.07 category: 13 legs × 100 shares × 0.07 × 0.069 ≈ $6.30. Suddenly the trade needs to print around 0.93 or deeper just to break even after slippage. This is the fee wall most people run into: in fee-heavy categories, sub-par prices are not automatically arbitrage.
  • Slippage buffer: 13 legs → $0.78

Net in the zero-fee case: $100 − ($96.50 + $0.78) = +$2.72 per $100 face (+2.8%), locked until resolution. The same dislocation in a finance-tagged field nets you roughly −$3.50 — a loss on a “guaranteed” structure. That is why the scanner classifies each field’s fee category instead of computing one blended rate: the category decides whether a 0.965 print is money or a trap.

The ways this bleeds you

  • Resolution risk: you’re holding until the market resolves per its written rules. Disputed or delayed resolutions lock capital indefinitely. Read the resolution source on any field before buying all 57 legs of it.
  • The partial-book trap: covered above. If a scanner shows you a sum over a subset of candidates, it’s not an arbitrage, it’s a donation.
  • Slippage on the tail legs: the last legs you fill are usually the thinnest. The scanner’s buffer ($0.60 per $100 face per 10 legs) is not optional padding — it’s the difference between the backtest and the fill.
  • Fee category surprises: Polymarket’s rates vary by category and have changed during 2026 (finance and sports markets gained taker fees mid-year). Check the current fee schedule before sizing anything; the rates in the script were correct at publish time.
  • KYC/geo: access to these platforms depends on where you live and which entity serves your region. That’s your compliance problem to solve before your trading problem.

The structure is real, the math checks, the edge is thin and occasional and completely dependent on having a machine watching while you sleep. That’s the entire pitch — and unlike most pitches with the word “arbitrage” in them, this one survives having its numbers checked.