αAlphaware
QUANT7분 읽기

Polymarket 가격을 이벤트 확률 데이터로 쓰기 전에: 공개 API, 식별자, 유동성 필터

Polymarket의 공개 API로 마켓·호가·가격 이력을 읽는 구조, 마켓 ID와 결과 토큰 ID의 구분, 호가 품질 필터와 시점 정렬을 거쳐 이벤트 확률을 검증 가능한 데이터로 만드는 방법입니다.

Polymarket 가격을 이벤트 확률 데이터로 쓰기 전에: 공개 API, 식별자, 유동성 필터

업데이트: 2026년 11월 3일. Polymarket 공식 문서에서 확인한 데이터 접근 방식과, 이를 리서치 데이터로 다루는 설계를 구분해 설명합니다. 코드의 응답과 수치는 문서 형식을 따른 가상 예시이며, 실제 시장 데이터나 매매 성과가 아닙니다.

예측시장 가격은 확률 추정치이자 거래 가격이다

Polymarket에서 각 결과(Yes/No) 지분은 0달러에서 1달러 사이에서 거래되고, 공식 문서는 이 가격을 해당 결과가 일어날 확률에 대한 시장의 판단으로 설명합니다. 결과가 맞으면 지분 하나가 1달러로 상환되고, 틀리면 0이 됩니다. 거래는 사용자끼리 주문을 맞추는 CLOB(중앙 지정가 주문장)에서 이뤄지며, 주문 매칭은 오프체인, 정산은 Polygon 위의 스마트 계약에서 처리됩니다.

데이터로 쓸 때 먼저 알아야 할 점은 화면에 표시된 확률이 하나의 정의된 계산값이라는 것입니다. 문서에 따르면 표시 가격은 매수·매도 호가의 중간값이고, 스프레드가 0.10달러보다 넓으면 마지막 체결가를 대신 보여 줍니다. 호가가 얇은 마켓에서는 같은 “확률”이 오래된 체결 하나를 뜻할 수도 있습니다.

다른 확률 지표와도 계산 방식이 다릅니다. CME FedWatch는 30일 연방기금 금리 선물 가격에서 회의별 금리 변경 확률을 역산하며, 변경 폭이 25bp 단위라는 가정을 둡니다. 예측시장 가격은 특정 문장으로 정의된 결과에 대한 직접 거래 가격입니다. 둘 중 어느 쪽이 더 정확하다는 일반적인 결론은 이 글에서 다루지 않습니다. 비교하려면 같은 사건, 같은 시점, 같은 결과 정의로 맞춘 뒤 검증해야 합니다.

공개 데이터는 목적이 다른 세 API로 나뉜다

공식 문서는 시장 데이터 조회를 인증 없이 할 수 있다고 설명합니다. 주문 생성·취소 같은 개인 요청에는 지갑 서명(L1)과 API 자격 증명 기반 HMAC 서명(L2)이 필요합니다. 이 글은 읽기 전용 조회만 다룹니다.

API 기본 주소 이 글에서 쓰는 용도
Gamma API https://gamma-api.polymarket.com 이벤트·마켓 탐색, 메타데이터, 결과 식별자
CLOB API https://clob.polymarket.com 현재 호가(/book), 최우선 가격(/price), 중간값(/midpoint), 스프레드(/spread)
Data API https://data-api.polymarket.com 가격 이력(/v2/prices-history), 활동·포지션 분석

가격 이력 엔드포인트에는 확인할 제약이 있습니다. start/end로 지정한 절대 구간은 요청당 최대 15일이고, 상대 구간(interval), 절대 구간, 특정 시점 조회(as_of) 중 하나만 고릅니다. 세밀한 해상도는 보존 기간이 짧다고 문서에 적혀 있으므로, 장기 백테스트용 고해상도 데이터가 필요하면 직접 수집·저장 계획이 필요합니다. 엔드포인트별 요청 한도도 공식 Rate Limits 문서에 따로 있습니다.

마켓 ID와 결과 토큰 ID를 혼동하지 않는다

Polymarket의 데이터 모델은 이벤트, 마켓, 결과의 세 단계입니다. 이벤트는 하나 이상의 마켓을 묶고, 각 마켓은 Yes/No 결과를 가진 질문이며, 가격과 호가는 결과마다 따로 있습니다.

  • id, conditionId는 마켓을 가리킵니다.
  • 가격·호가 조회의 token_id에는 결과의 식별자를 넣습니다. 기존(CTF) 마켓은 Gamma 응답의 clobTokenIds가 JSON 배열을 담은 문자열이고, Polymarket Protocol V2 마켓은 positionIds 배열을 씁니다. version 필드로 구분합니다.

마켓의 conditionId를 가격 조회의 token_id 자리에 넣거나, Yes와 No 중 어느 쪽 가격인지 기록하지 않으면, 나중에 시계열의 의미를 복원할 수 없습니다.

요청 생성과 응답 파싱을 분리해 테스트한다

네트워크 호출과 해석 로직을 섞으면 응답 형식이 바뀌었을 때 어디서 깨졌는지 알기 어렵습니다. 아래 코드는 URL 생성과 응답 파싱만 담고, 공식 문서의 응답 형식을 본뜬 가상 응답으로 검증합니다. 실제 요청은 이 URL을 원하는 HTTP 클라이언트로 보내면 됩니다.

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 마켓은 JSON 문자열 clobTokenIds, V2 마켓은 배열 positionIds를 쓴다."""
    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"]]

# --- 아래는 공식 문서 형식을 따른 가상 응답이다. 네트워크를 쓰지 않는다. ---
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")   # 0.42 매수 120 + 0.47 매도 80
assert history[1] == (1788357600, Decimal("0.262"))
try:
    history_url("111", 0, 16 * 24 * 3600)
    raise AssertionError("range check failed")
except ValueError:
    pass

가격과 수량은 문자열로 오므로 Decimal로 읽으면 반올림 오차 없이 비교할 수 있습니다. 실제 수집 코드에는 수집 시각, 결과 식별자, 응답 원본 해시를 함께 저장해 두는 편이 재현에 유리합니다.

신호 후보는 호가 품질 필터를 통과한 변화량으로 정의한다

확률의 수준보다 변화가 새 정보의 유입을 더 직접적으로 보여 줄 수 있다는 것은 시험해 볼 가설입니다. 그 전에 두 가지를 정리해야 합니다. 하나는 호가가 넓거나 얕은 시점의 값을 걸러 내는 것이고, 다른 하나는 비교할 자산 수익률을 확률 관측 이후 구간으로 붙이는 것입니다.

import pandas as pd

# 가상의 1시간 확률 시계열과 같은 시점의 호가 상태
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):
    """호가가 넓거나 얕은 시점은 버리고, 남은 관측끼리의 확률 변화를 계산한다."""
    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시는 필터에서 제외
assert abs(changes.loc[changes["ts"].dt.hour == 4, "d_prob"].item() - 0.15) < 1e-12

# 비교할 자산 수익률은 확률 관측 '이후' 구간으로 붙여야 한다
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()        # 수익률 구간은 확률 관측 뒤에 시작
assert list(joined["ret_next_1h"]) == [0.001, -0.004, 0.002]

필터 기준(스프레드 0.05, 깊이 1,000)은 설명용 값입니다. 실제 기준은 마켓별 호가 분포를 보고 정하고, 결과를 보기 전에 고정합니다. 걸러 낸 뒤에 생긴 시간 간격은 변화량의 크기에 영향을 주므로 간격 자체도 기록합니다.

확률이 특정 수준을 넘으면 위험자산을 늘린다는 식의 고정 규칙은 근거를 확인하지 못해 이 글에서 제시하지 않습니다. 확률 변화와 자산 수익률의 관계를 보려면, 사건 발생 전후를 비교하는 이벤트 연구 형태로 표본을 모으고, 같은 기간의 다른 정보(경제지표 발표, 금리 선물 변화)를 통제한 뒤, 과적합 점검 칼럼의 시간순 검증을 적용하는 순서가 필요합니다.

해석과 이용 전에 확인할 한계

  • 유동성: 문서는 주문 크기 제한이 없지만 큰 주문은 가격을 크게 움직일 수 있으니 호가 깊이를 확인하라고 안내합니다. 얕은 마켓의 확률은 소수 참여자의 주문으로 움직일 수 있습니다.
  • 결과 정의와 판정: 확률은 마켓 문장에 적힌 결과에 대한 가격입니다. UMA Optimistic Oracle을 쓰는 마켓은 결과 제안, 이의 제기 기간, 분쟁 시 UMA 판정을 거쳐 확정됩니다. 판정 규칙이 연구하려는 경제적 사건과 정확히 같은지 확인해야 합니다.
  • 여러 결과가 있는 이벤트: 한 이벤트에 여러 마켓이 있으면 각 Yes 가격의 합이 1과 다를 수 있습니다. 확률 분포로 쓰려면 정규화 여부와 방법을 명시합니다.
  • 지역 제한과 약관: 공식 문서의 지역 제한 페이지는 OFAC 제재 지역을 완전 차단으로, 미국·영국·프랑스 등 다수 국가를 프런트엔드와 API 모두에서 신규 주문 불가(기존 포지션 청산만 가능)로 분류합니다. 대한민국은 프런트엔드에서만 청산 전용이고 API 자체는 제한되지 않는다고 적혀 있습니다. 이 목록은 바뀔 수 있으므로 이용 전 공식 지역 제한 문서와 이용약관, 거주 국가의 법규를 직접 확인해야 합니다. 이 글은 공개 데이터 조회만 다루며 거래 참여를 권하지 않습니다.

자료와 적용 범위

반응

이 브리프가 도움이 됐나요?

구독

새 글 받아보기

새 브리프가 올라가면 이메일로 보냅니다. RSS와 X도 그대로 있습니다.

구독하기
같은 카테고리 더 보기QUANT →