There is a corner of Polymarket where you can make money without ever being right about an election, a football match, or a Fed decision. You post limit orders on both sides of a market’s order book. The platform pays you a slice of a daily USDC pool for keeping that book tight. Your orders never even need to fill. Polymarket’s own documentation describes the program in one line: by posting resting limit orders, liquidity providers are automatically eligible for its incentive program, and rewards are distributed directly to maker addresses daily at midnight UTC (docs.polymarket.com/programs/liquidity-rewards).

This is the closest thing prediction markets have to selling picks and shovels: the bettors argue about who wins, and you get paid for standing in the middle with a two-sided quote. No opinion required.

That is the pitch. The reality is more interesting, because this is a formula game, and almost nobody reads the formula. This article walks through exactly how the reward is computed, what the order book data actually says about where the money is, and ships a complete, tested Python scanner that pulls every reward-eligible market live and ranks them by an LP score built from pool size, competition, price band, spread health, and time to resolution.

How the payment actually works

Every reward-eligible market carries a daily pool. For example, on the day this was written (31 October 2026), the market “Will Benjamin Netanyahu be the next Prime Minister of Israel?” carried a rate_per_day of 3,000 USDC, and a 2026 Senate balance-of-power market carried 2,500 USDC — both read live from Polymarket’s public rewards endpoint. The key misunderstanding, and the reason most first-timers get disappointed: the pool is what everyone splits, not what you receive.

Your share is determined by a scoring formula run on sampled order book snapshots — up to 1,440 samples per UTC day, one per minute at a random offset. Polymarket’s official methodology boils down to this:

1. Each of your resting orders gets a score based on how close it sits to the midpoint:

S(v, s) = ((v - s) / v)^2 * b

where v is the market’s max qualifying spread (in cents), s is your order’s spread from the midpoint, and b is an in-game multiplier. The penalty is quadratic: an order 1 cent away scores roughly (2/3)^2 ≈ 0.44 of max, 3 cents away in a 3-cent market scores zero. Quoting at the front of the queue is not a nicety, it is most of the score.

2. Your two-side scores are combined with a minimum rule:

If midpoint is in [0.10, 0.90]:
    Q_min = max(min(Q_one, Q_two), max(Q_one / 3, Q_two / 3))
If midpoint is outside [0.10, 0.90]:
    Q_min = min(Q_one, Q_two)

The first branch rewards two-sided quoting but still pays single-sided makers at a third of the score (the scaling factor c is currently 3.0 on all markets, per Polymarket’s methodology page). The second branch is brutal: outside the 10–90 cent band, single-sided liquidity scores nothing. If a market trades at 94¢, you must quote both sides or earn nothing.

3. Your share of the pool is your normalized score share:

Q_final = Q_epoch / sum(all market makers' Q_epoch)   ->   your payout = Q_final * pool

And the enforcement details from the docs:

  • Minimum reward payout is $1 per day. Earn 90 cents on a given day and you are paid nothing. It does not roll over. Each day stands alone.
  • Each market defines rewards_min_size (orders below it do not count — e.g. 200 shares on the Netanyahu market) and rewards_max_spread (orders beyond it do not count).
  • Makers are never charged trading fees. On fee-bearing markets, takers pay (crypto 7%, sports 5%, finance/politics 4% of notional × p × (1−p) per Polymarket’s fee table), and those fees fund a separate Maker Rebates program paying daily rebates of 15–25% back to makers on top of rewards.

What you can realistically earn (the honest numbers)

The full-time bot crowd in the early days reported 200–300 USDC per day on ~10,000 USDC of capital. Those numbers are years out of date. Two datapoints from 2026 put today’s reality in perspective:

  • Polymarket paid out over $5 million in liquidity rewards on sports and esports markets in April 2026 alone (predacle.com/guides/lp-rewards), and a single Premier League match can carry a $10,000 daily pool.
  • The same source notes that cumulative LP earnings of $100 put a wallet in roughly the top 6% of all Polymarket wallets, out of ~3.1 million wallets and only ~52,000 that have ever farmed rewards at all. A detailed two-week postmortem by a full-time LP concluded rewards are now “a thin bonus on top of real trading edge”, with roughly 10% annualized a realistic target on calm, long-dated markets.

Why the collapse? Pools are split proportionally, so every new LP thins everyone’s slice. A juicy-looking pool is often juicy because it is already crowded — and the market with a modest pool and almost nobody in it can pay better than the headline number. Finding those quiet pools across hundreds of markets is precisely what code is for.

The scanner: rank every reward market in one run

Everything below uses Polymarket’s public, unauthenticated CLOB endpoints. No API key, no wallet, no orders placed — it is read-only market selection. Two endpoints do all the work:

  • GET https://clob.polymarket.com/rewards/markets/multi — every reward-configured market with its pool, max spread, and min order size
  • GET https://clob.polymarket.com/book?token_id=... — the live order book, for competition depth and spread health
  • GET https://clob.polymarket.com/midpoint?token_id=... — the live midpoint (more on why this matters in a second)

The scanner scores each market on four axes: price band safety (how far the live midpoint sits from the 10¢/90¢ edges where single-sided quoting dies, 35% weight), spread health (is the current book spread inside the qualifying band, 20%), depth efficiency (pool size relative to the shares already resting in-band — your likely slice, 30%), and long-dated bonus (weeks-to-months markets need less babysitting, 15%). It also flags markets where 24h volume is enormous relative to the pool — a toxicity warning, because heavy flow is what fills your quotes at the worst moments.

One data gotcha the hard way: the price fields returned alongside reward configs can be stale. While testing, one halftime-show market returned price: 0.0485/0.9515 on its token pair while the live book midpoint was 0.0485 — and a naive midpoint derived from config-side prices read 0.50 for the same market. Using the stale value, the scanner estimated a min-size LP would capture ~99% of the pool. Using the live /midpoint endpoint, the same market showed ~22,000 shares of competing in-band depth. Always re-derive the midpoint from the book, never from cached config prices.

#!/usr/bin/env python3
"""
Polymarket LP Rewards Scanner (read-only).
Ranks every reward-eligible Polymarket market by an LP score built from:
pool size, competition proxy, price band safety, spread health, time to resolution.

Uses ONLY public, unauthenticated endpoints:
  - https://clob.polymarket.com/rewards/markets/multi   (reward configs per market)
  - https://clob.polymarket.com/book?token_id=...       (live order book depth)
No API key, no wallet, no orders placed. educational/research use.

DISCLOSURE: tested live with public endpoints (no mocking needed).
"""

import json
import sys
import time
import urllib.request
import urllib.parse
from datetime import datetime, timezone

CLOB = "https://clob.polymarket.com"

# ---- tunables (edit to taste) ----
MIN_POOL_PER_DAY = 100.0        # ignore markets with smaller reward pools
PAGE_SIZE = 100                 # max 500 per docs
MAX_PAGES = 5                   # how many pages of reward markets to scan
TOP_N = 15                      # how many markets to print
FEE_RATE_BY = {                 # taker fee rates -> proxy for flow toxicity (docs.polymarket.com/trading/fees)
    "sports": 0.05, "crypto": 0.07,
}  # only used for narrative hints, not scoring


def http_json(url, params=None, retries=3):
    """GET with retries; returns parsed JSON."""
    if params:
        url = url + "?" + urllib.parse.urlencode(params)
    last_err = None
    for attempt in range(retries):
        try:
            req = urllib.request.Request(url, headers={"User-Agent": "lp-scanner/1.0 (research)"})
            with urllib.request.urlopen(req, timeout=30) as r:
                return json.loads(r.read().decode("utf-8"))
        except Exception as e:  # noqa: BLE001 - we want to retry anything
            last_err = e
            time.sleep(1.5 * (attempt + 1))
    raise RuntimeError(f"GET failed after {retries} tries: {url} -> {last_err}")


def band_score(mid):
    """
    1.0 at mid=0.50, falls to 0 at the 0.10/0.90 edges where single-sided
    quoting stops scoring entirely (Polymarket liquidity rewards methodology).
    """
    if mid is None:
        return 0.0
    if mid < 0.10 or mid > 0.90:
        return 0.0
    return 1.0 - abs(mid - 0.50) / 0.40


def in_band_depth(book, mid, max_spread_cents):
    """
    Total share-denominated size of resting orders that would actually score:
    every level within max_spread cents of the midpoint, both sides.
    Polymarket's reward formula counts ALL qualifying size, not just top-of-book,
    so this is the honest competition denominator.
    """
    total = 0.0
    for side in ("bids", "asks"):
        for lvl in book.get(side) or []:
            try:
                px, sz = float(lvl["price"]), float(lvl["size"])
            except (KeyError, TypeError, ValueError):
                continue
            if abs(px - mid) * 100.0 <= max_spread_cents:
                total += sz
    return total


def competition_proxy(book, mid=None, max_spread_cents=3.0):
    return in_band_depth(book, mid, max_spread_cents)


def spread_cents(book):
    """Best ask - best bid, in cents. None if book is one-sided/empty."""
    bids = book.get("bids") or []
    asks = book.get("asks") or []
    if not bids or not asks:
        return None
    try:
        best_bid = max(float(b["price"]) for b in bids)
        best_ask = min(float(a["price"]) for a in asks)
    except (KeyError, TypeError, ValueError):
        return None
    return (best_ask - best_bid) * 100.0


def days_left(end_date):
    """Days until market end_date (UTC). None if unparseable -> treated as long-dated."""
    if not end_date:
        return None
    s = end_date.strip()
    # normalize "+00" -> "+0000" so %z can parse it
    if s.endswith("+00"):
        s = s + "00"
    fmts = ("%Y-%m-%d %H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ",
            "%Y-%m-%d %H:%M:%S.%f%z", "%Y-%m-%d")
    for fmt in fmts:
        try:
            dt = datetime.strptime(s, fmt)
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            return max(0.0, (dt - datetime.now(timezone.utc)).total_seconds() / 86400.0)
        except ValueError:
            continue
    return None


def main():
    print(f"[*] Polymarket LP Rewards Scanner  (UTC now: {datetime.now(timezone.utc):%Y-%m-%d %H:%M})")
    markets, page, cursor = [], 0, None
    while page < MAX_PAGES:
        params = {"order_by": "rate_per_day", "position": "DESC", "page_size": PAGE_SIZE}
        if cursor:
            params["cursor"] = cursor
        data = http_json(f"{CLOB}/rewards/markets/multi", params)
        batch = data.get("data") or []
        markets.extend(batch)
        cursor = data.get("next_cursor")
        page += 1
        if not cursor or not batch:
            break
        time.sleep(0.4)
    print(f"[*] fetched {len(markets)} reward-configured markets across {page} page(s)")

    # ---- filter: real pool, sane band ----
    candidates, seen_slugs = [], set()
    for m in markets:
        slug = m.get("market_slug")
        if slug and slug in seen_slugs:
            continue
        if slug:
            seen_slugs.add(slug)
        cfgs = m.get("rewards_config") or []
        pool = sum(float(c.get("rate_per_day", 0) or 0) for c in cfgs)
        if pool < MIN_POOL_PER_DAY:
            continue
        toks = m.get("tokens") or []
        if len(toks) != 2:
            continue
        prices = [t.get("price") for t in toks]
        if not all(isinstance(p, (int, float)) for p in prices):
            continue
        mid = (prices[0] + prices[1]) / 2.0
        dl = days_left(m.get("end_date"))
        # skip anything resolving within 2 days (gamma risk) or already past
        if dl is not None and dl < 3.0:
            continue
        candidates.append({
            "question": (m.get("question") or "")[:70],
            "slug": m.get("market_slug"),
            "pool": pool,
            "mid": mid,
            "max_spread": m.get("rewards_max_spread"),
            "min_size": m.get("rewards_min_size"),
            "days_left": dl,
            "vol24h": float(m.get("volume_24hr", 0) or 0),
            "token_id": toks[0].get("token_id"),
        })
    print(f"[*] {len(candidates)} pass pool>=${MIN_POOL_PER_DAY:.0f}/day + >2 days to resolution")

    # ---- probe order books (rate-limited politely) ----
    scored = []
    for i, c in enumerate(candidates[:120]):  # cap book probes to keep runtime sane
        try:
            book = http_json(f"{CLOB}/book", {"token_id": c["token_id"]})
        except RuntimeError as e:
            print(f"    ! book fetch failed for {c['slug']}: {e}")
            continue
        # the reward-relevant midpoint is the LIVE book midpoint; the price fields
        # returned alongside reward configs can be stale (verified 2026-10-31: one
        # market listed price 0.0485/0.9515 while config implied mid=0.50)
        try:
            midp = http_json(f"{CLOB}/midpoint", {"token_id": c["token_id"]})
            live_mid = float(midp.get("mid"))
            if 0.0 < live_mid < 1.0:
                c["mid"] = live_mid
        except (RuntimeError, KeyError, TypeError, ValueError):
            pass  # fall back to config-derived mid
        sc = spread_cents(book)
        comp = competition_proxy(book, c["mid"], float(c["max_spread"] or 3.0))
        bs = band_score(c["mid"])
        max_sp = float(c["max_spread"] or 3.0)
        # spread health: inside qualifying band => full marks, decays outside
        sh = max(0.0, 1.0 - max(0.0, sc - max_sp) / 10.0) if sc is not None else 0.0
        # pool per $ of standing in-band depth: how fat your slice could be
        depth_eff = c["pool"] / max(comp, 1.0)
        # long-dated bonus: weeks-to-months markets need less babysitting
        tdl = c["days_left"] if c["days_left"] is not None else 30.0
        time_score = min(tdl / 30.0, 1.0)
        # volume guard: insanely high vol vs pool => toxic flow warning
        vol_ratio = c["vol24h"] / max(c["pool"], 1.0)
        c.update({
            "spread_c": sc, "inband": comp, "depth_eff": depth_eff,
            "band": bs, "spread_health": sh, "time_score": time_score,
            "vol_ratio": vol_ratio,
        })
        c["lp_score"] = (bs * 0.35 + sh * 0.20 + min(depth_eff, 2.0) / 2.0 * 0.30 + time_score * 0.15)
        scored.append(c)
        if (i + 1) % 20 == 0:
            print(f"    ... probed {i+1}/{min(len(candidates),120)} books")
        time.sleep(0.15)

    scored.sort(key=lambda x: x["lp_score"], reverse=True)

    print("\n=== TOP PICKS (ranked by LP score: band 35% | spread 20% | depth-efficiency 30% | long-dated 15%) ===")
    hdr = f"{'score':>5}  {'pool/d':>7}  {'mid':>4}  {'sprd':>5}  {'maxsp':>5}  {'inband':>8}  {'dleft':>5}  {'vol/pool':>8}  question"
    print(hdr)
    for c in scored[:TOP_N]:
        warn = "  << toxic flow?" if c["vol_ratio"] > 300 else ""
        print(f"{c['lp_score']:5.2f}  {c['pool']:7.0f}  {c['mid']:4.2f}  "
              f"{(c['spread_c'] if c['spread_c'] is not None else -1):5.1f}  {c['max_spread'] or 0:5.1f}  "
              f"{c['inband']:8.0f}  {(c['days_left'] if c['days_left'] is not None else -1):5.0f}  "
              f"{c['vol_ratio']:8.0f}  {c['question']}{warn}")

    print("\n=== SAMPLE BACKTEST: score share if you posted min-size at best 3 levels ===")
    # Rough intuition check, NOT a promise: assumes you add min_size next to existing depth.
    for c in scored[:TOP_N]:
        minsz = float(c["min_size"] or 100)
        yours = minsz * 6  # 3 levels x 2 sides, all in-band (optimistic)
        share = yours / (c["inband"] + yours)
        est = share * c["pool"]
        flag = "PAYABLE" if est >= 1.0 else "under $1/day floor -> paid NOTHING"
        print(f"  {c['question'][:50]:50s} est ~${est:6.2f}/day  ({share*100:4.1f}% of pool)  {flag}")

    print("\n[!] Read this: pools are what EVERYONE splits. est figures assume you keep orders")
    print("    in-band 24/7 and nobody else shows up. Adverse selection can eat all of it.")
    return 0


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

Reading the output like an LP, not like a tourist

Run it and you get three layers of information (this is the actual output of the run executed on 31 October 2026; pools and books shift daily, so your numbers will differ):

[*] Polymarket LP Rewards Scanner  (UTC now: 2026-10-31 03:10)
[*] fetched 500 reward-configured markets across 5 page(s)
[*] 87 pass pool>=$100/day + >2 days to resolution
...
 0.67      239  0.37    2.0    4.5       407     31        13  Will Gemini Carbon be released on November 11, 2026?
 0.66      208  0.46    1.0    4.5     12391     76         2  Will GEN be number 1 on the end of year Global Power Rankings?
 0.64      452  0.43    1.0    4.5   1806237     29        86  Will Gen.G win Worlds 2026?
 0.62      273  0.41    0.1    6.5    713149    170       305  Will Marine Le Pen win the 2027 French presidential election?  << toxic flow?

And the sample backtest section, which is where the $1 floor stops being a footnote:

 Will Gemini Carbon be released on November 11, 202 est ~$101.35/day  (42.4% of pool)  PAYABLE
 Will Gen.G win Worlds 2026?                        est ~$  0.08/day  ( 0.0% of pool)  under $1/day floor -> paid NOTHING
 Will Marine Le Pen win the 2027 French presidentia est ~$  0.23/day  ( 0.1% of pool)  under $1/day floor -> paid NOTHING
 Will the Fed increase interest rates by 25 bps aft est ~$  0.17/day  ( 0.0% of pool)  under $1/day floor -> paid NOTHING

Same pools, wildly different verdicts. The World Championship esports market carries a $452/day pool — and 1.8 million shares of competing in-band depth, so a min-size LP earns 8 cents a day and breaches the $1 floor, i.e. gets paid literally nothing. The Gemini Carbon date market with a $239 pool and 407 shares in-band is wide open. That inversion — modest pool, thin competition, beats monster pool with a crowd — is the entire game, and it is invisible without pulling the book.

What the estimates do and do not mean. They assume your orders sit in-band for the full day and nobody else shows up. In reality: your slice shrinks as others arrive, the midpoint drifts and drags your orders out of the band until you re-post, and every fill is a position you now hold. Treat the numbers as a ceiling, not a promise.

The three risks that eat the yield

Adverse selection. Your resting orders are standing invitations to trade with anyone — including the person who just read the news before you did. When a market is about to resolve or a catalyst lands, informed flow hits your bid at exactly the wrong price. This is structural, not bad luck. It is why the scanner’s volume-to-pool ratio is a warning flag, and why calm, long-dated, low-catalyst markets (the halftime-show and 2028-House type) are the sensible training ground.

Inventory risk. Near 50¢, your two sides fill roughly evenly and inventory stays balanced. At the extremes it does not: quote around 90¢, get filled on the cheap side, and watch the position collapse when the probability flips. Polymarket’s own documentation walks the example — $100 committed when YES trades at 90¢ is worth a fraction of that if YES falls to 50¢. Rewards on that market might pay a few dollars a day. The drawdown can dwarf months of them.

Program and floor risk. These are incentive programs, not contracts. Polymarket adjusts pools continuously and ran time-boxed allocations (its crypto TWAP liquidity program, for instance, was a one-month $1M pool that has since ended). The $1 daily floor silently zeroes out small participants on crowded markets. And Polymarket geo-blocks certain jurisdictions — check eligibility before building anything on top.

None of this is financial advice, and no tool — including this one — can promise you an exact return, because Polymarket does not publish total in-band liquidity per market. Any dashboard quoting a precise guaranteed APR is hiding an assumption.

From scanner to operation

Once the scanner finds a market worth quoting, executing is a separate discipline: Polymarket’s official Python client (py-clob-client, MIT-licensed on GitHub) handles L1/L2 auth, order signing, and GTC limit orders — and client.get_total_earnings_for_user_for_day() reconciles what actually landed against what the scanner estimated. That loop — estimate, quote, reconcile, re-post — is the job. The scanner is the part that never sleeps, never tilts, and reads 500 markets in under two minutes; running it daily on a cheap VPS turns market selection from a full-time eyeball job into a ranked list waiting for you each morning.

The shovel-seller framing, one last time: the $5M/month in rewards is real, paid, and largely ignored by the betting crowd — because the betting crowd is busy having opinions. You do not need one. You need tight quotes, a market nobody else is in, and the discipline to walk away from pools that look big but split too many ways.

Tested note: the scanner in this article was executed live on 31 October 2026 against Polymarket’s public CLOB endpoints (exit code 0, 500 markets fetched, 87 probed) — no mocking needed, everything is unauthenticated read-only data. Config-dependent numbers (pool sizes, spreads, depths) were verified the same day via docs.polymarket.com and the live API.