αAlphaware
QUANT7 min read

Before using Polymarket prices as event-probability data: public APIs, identifiers, and liquidity filters

How Polymarket's public APIs expose markets, order books, and price history, why market IDs and outcome token IDs must be kept apart, and how quote-quality filters and time alignment turn event probabilities into testable data.

Before using Polymarket prices as event-probability data: public APIs, identifiers, and liquidity filters

Updated November 3, 2026. This article separates what Polymarket’s official documentation says about data access from the design we propose for handling it as research data. Responses and numbers in the code are hypothetical examples shaped like the documented formats, not real market data or trading results.

A prediction-market price is both a probability estimate and a trade price

On Polymarket each outcome share (Yes/No) trades between $0 and $1, and the official documentation describes that price as the market’s belief in the probability of the outcome. A winning share is redeemable for $1 and a losing share is worth $0. Trading happens on a CLOB (central limit order book) where users’ orders are matched with each other; matching is offchain and settlement runs through smart contracts on Polygon.

The first thing to know when using it as data is that the displayed probability is one specific calculation. According to the documentation, the displayed price is the midpoint of the bid-ask spread, and if the spread is wider than $0.10 the last traded price is shown instead. In a thin market the same “probability” may reflect a single stale trade.

It is also computed differently from other probability gauges. CME FedWatch backs out meeting-by-meeting rate-change probabilities from 30-Day Fed Funds futures prices, assuming moves in 25bp increments. A prediction-market price is a direct trade price on an outcome defined by a specific sentence. This article does not draw a general conclusion that one is more accurate; comparing them requires aligning the same event, time, and outcome definition and then testing.

Public data is split across three APIs with different purposes

The official documentation says market data can be read without credentials. Private requests such as creating or cancelling orders need a wallet signature (L1) and an HMAC signature based on API credentials (L2). This article covers read-only access only.

API Base URL Use in this article
Gamma API https://gamma-api.polymarket.com Discover events and markets, metadata, outcome identifiers
CLOB API https://clob.polymarket.com Current book (/book), best price (/price), midpoint (/midpoint), spread (/spread)
Data API https://data-api.polymarket.com Price history (/v2/prices-history), activity and position analytics

The price history endpoint has constraints to check. An absolute range set with start/end spans at most 15 days per request, and you choose exactly one of a relative window (interval), an absolute range, or a point-in-time read (as_of). The documentation states that finer resolutions are retained for shorter periods, so if you need high-resolution data for long backtests you need a plan to collect and store it yourself. Per-endpoint request limits are listed separately in the official Rate Limits page.

Keep market IDs and outcome token IDs apart

Polymarket’s data model has three levels: event, market, and outcome. An event groups one or more markets, each market is a question with Yes and No outcomes, and prices and order books exist per outcome.

  • id and conditionId identify the market.
  • The token_id in price and book requests takes the outcome’s identifier. For existing (CTF) markets, Gamma’s clobTokenIds is a string containing a JSON array; Polymarket Protocol V2 markets use a positionIds array instead. The version field tells them apart.

If you pass a market’s conditionId where a price request expects token_id, or fail to record whether a series is the Yes or the No price, the meaning of the time series cannot be recovered later.

Separate request building from response parsing and test them

Mixing network calls with interpretation logic makes it hard to tell where things broke when a response format changes. The code below contains only URL building and response parsing, and tests them on hypothetical responses modeled on the documented formats. For real requests, send these URLs with any HTTP client.

import json
from decimal import Decimal
from urllib.parse import urlencode

GAMMA = "https://gamma-api.polymarket.com"
CLOB = "https://clob.polymarket.com"
DATA = "https://data-api.polymarket.com"

def markets_url(tag_id, limit=20, cursor=None):
    params = {"tag_id": tag_id, "closed": "false", "limit": limit}
    if cursor:
        params["after_cursor"] = cursor
    return f"{GAMMA}/markets/keyset?{urlencode(params)}"

def book_url(asset_id):
    return f"{CLOB}/book?{urlencode({'token_id': asset_id})}"

def history_url(asset_id, start, end, bucket_seconds=3600):
    if end - start > 15 * 24 * 3600:
        raise ValueError("explicit ranges are limited to 15 days per request")
    params = {"token_id": asset_id, "start": start, "end": end,
              "bucket_seconds": bucket_seconds}
    return f"{DATA}/v2/prices-history?{urlencode(params)}"

def outcome_ids(market):
    """CTF markets use the JSON-string clobTokenIds; V2 markets use the positionIds array."""
    if market.get("version") == "v2":
        ids = market["positionIds"]
    else:
        ids = json.loads(market["clobTokenIds"])
    return {"market_id": market["id"], "condition_id": market["conditionId"],
            "yes": ids[0], "no": ids[1]}

def book_summary(book, depth_band=Decimal("0.03")):
    bids = sorted((Decimal(l["price"]), Decimal(l["size"])) for l in book["bids"])
    asks = sorted((Decimal(l["price"]), Decimal(l["size"])) for l in book["asks"])
    best_bid, best_ask = bids[-1][0], asks[0][0]
    mid = (best_bid + best_ask) / 2
    near = lambda side: sum(s for p, s in side if abs(p - mid) <= depth_band)
    return {"bid": best_bid, "ask": best_ask, "mid": mid,
            "spread": best_ask - best_bid,
            "depth_near_mid": near(bids) + near(asks)}

def parse_history(payload):
    return [(int(p["timestamp"]), Decimal(str(p["price"]))) for p in payload["data"]]

# --- Hypothetical responses in the documented format. No network access. ---
sample_market = {
    "id": "1001", "version": "v1", "conditionId": "0xabc",
    "question": "Hypothetical event?",
    "clobTokenIds": "[\"111\", \"222\"]", "positionIds": None,
}
sample_book = {
    "asset_id": "111",
    "bids": [{"price": "0.40", "size": "500"}, {"price": "0.42", "size": "120"}],
    "asks": [{"price": "0.47", "size": "80"}, {"price": "0.60", "size": "900"}],
}
sample_history = {"data": [
    {"timestamp": 1788354000, "price": 0.255, "resolution_seconds": 3600},
    {"timestamp": 1788357600, "price": 0.262, "resolution_seconds": 3600},
]}

ids = outcome_ids(sample_market)
summary = book_summary(sample_book)
history = parse_history(sample_history)

assert ids == {"market_id": "1001", "condition_id": "0xabc", "yes": "111", "no": "222"}
assert outcome_ids({"id": "2", "version": "v2", "conditionId": "0xdef",
                    "positionIds": ["9", "10"]})["no"] == "10"
assert book_url(ids["yes"]) == "https://clob.polymarket.com/book?token_id=111"
assert "tag_id=745" in markets_url(745) and "closed=false" in markets_url(745)
assert summary["mid"] == Decimal("0.445") and summary["spread"] == Decimal("0.05")
assert summary["depth_near_mid"] == Decimal("200")   # bid 120 at 0.42 + ask 80 at 0.47
assert history[1] == (1788357600, Decimal("0.262"))
try:
    history_url("111", 0, 16 * 24 * 3600)
    raise AssertionError("range check failed")
except ValueError:
    pass

Prices and sizes arrive as strings, so reading them as Decimal allows comparisons without rounding error. In real collection code, storing the collection time, the outcome identifier, and a hash of the raw response alongside each value makes results easier to reproduce.

Define candidate signals as changes that pass a quote-quality filter

That changes in probability may show new information arriving more directly than its level is a hypothesis to test. Two things need to be settled first: filtering out observations where the book is wide or thin, and attaching the asset return for the window after each probability observation.

import pandas as pd

# Hypothetical hourly probability series with the book state at the same times
prob = pd.DataFrame({
    "ts": pd.to_datetime(["2026-10-01 00:00", "2026-10-01 01:00", "2026-10-01 02:00",
                          "2026-10-01 03:00", "2026-10-01 04:00"], utc=True),
    "mid": [0.40, 0.41, 0.55, 0.56, 0.70],
    "spread": [0.02, 0.02, 0.03, 0.12, 0.02],
    "depth": [5000, 5200, 4800, 300, 5100],
})

def usable_changes(df, max_spread=0.05, min_depth=1000):
    """Drop times with a wide or thin book, then compute changes between the remaining observations."""
    ok = df[(df["spread"] <= max_spread) & (df["depth"] >= min_depth)].copy()
    ok["d_prob"] = ok["mid"].diff()
    return ok.dropna(subset=["d_prob"])

changes = usable_changes(prob)
assert list(changes["ts"].dt.hour) == [1, 2, 4]          # 03:00 is filtered out
assert abs(changes.loc[changes["ts"].dt.hour == 4, "d_prob"].item() - 0.15) < 1e-12

# Attach the asset return for the window that starts after the probability observation
btc = pd.DataFrame({
    "ts": pd.to_datetime(["2026-10-01 01:30", "2026-10-01 02:30", "2026-10-01 04:30"], utc=True),
    "ret_next_1h": [0.001, -0.004, 0.002],
})
btc["btc_ts"] = btc["ts"]
joined = pd.merge_asof(changes.sort_values("ts"), btc.sort_values("ts"),
                       on="ts", direction="forward",
                       tolerance=pd.Timedelta("45min"), allow_exact_matches=False)
assert joined["ret_next_1h"].notna().all()
assert (joined["btc_ts"] > joined["ts"]).all()        # the return window starts after the observation
assert list(joined["ret_next_1h"]) == [0.001, -0.004, 0.002]

The filter thresholds (spread 0.05, depth 1,000) are illustrative. Set real thresholds from each market’s book distribution and fix them before looking at results. Filtering leaves gaps between observations that affect the size of each change, so record the gap as well.

A fixed rule such as “add risk when the probability crosses a level” is not presented here because we could not find support for it. To study how probability changes relate to asset returns, collect samples as an event study comparing before and after, control for other information in the same window (economic releases, rate-futures moves), and then apply the chronological validation from the overfitting checks article.

Limits to check before interpreting or using the data

  • Liquidity: the documentation notes that there are no order size limits but large orders can move the price significantly, and advises checking book depth. In a thin market, the probability can move on a few participants’ orders.
  • Outcome definition and resolution: the probability is a price on the outcome written in the market’s question. Markets using the UMA Optimistic Oracle are settled through a proposed outcome, a challenge period, and UMA resolution if disputed. Check that the resolution rules match the economic event you want to study.
  • Events with several outcomes: when one event contains several markets, the Yes prices may not sum to 1. If you use them as a probability distribution, state whether and how you normalize.
  • Geographic restrictions and terms: the official geographic restrictions page blocks OFAC-sanctioned regions completely and lists many countries, including the United States, the United Kingdom, and France, as close-only (no new orders, existing positions can be closed) on both the frontend and the API. South Korea is listed as close-only on the frontend only, with the API itself not restricted. The list can change, so check the official geographic restrictions page, the terms of use, and the laws of your country of residence before any use. This article covers reading public data only and does not encourage trading.

Sources and scope

Signal

Was this brief useful?

Subscribe

Get new briefs

Get an email when a new brief ships. RSS and X stay available.

Subscribe
More in this categoryQUANT →