Ключевые выводыCLOB и RTDS — разные WebSocket-системы: market/user сокеты CLOB требуют текстовый PING каждые 10 секунд, RTDS — каждые 5 секунд.
- CLOB и RTDS — разные WebSocket-системы: market/user сокеты CLOB требуют текстовый
PINGкаждые 10 секунд, RTDS — каждые 5 секунд. - Public market channel подписывается по outcome-token
assets_ids, а user channel — по condition IDs и требует L2 API credentials. book— полный snapshot,price_changeизменяет отдельные уровни, аsize: "0"удаляет уровень;last_trade_priceне заменяет состояние стакана.- После reconnect старый стакан нужно считать недействительным и дождаться нового
book, потому что документированного универсального sequence number для заполнения пропуска нет. - Успешный
PONGподтверждает heartbeat соединения, но не свежесть прикладных данных, поэтомуlast_data_ageнужно мониторить отдельно.
Краткое содержание подготовлено с помощью ИИ.
Два сокета, которые легко перепутать
У Polymarket нет одного WebSocket-адреса для всех потоковых данных. CLOB-сокет относится к торговой системе, а RTDS — отдельный поток внешних цен и комментариев.
| Поток | Endpoint | Чем подписываемся | Авторизация | Heartbeat |
|---|---|---|---|---|
| CLOB market | wss://ws-subscriptions-clob.polymarket.com/ws/market |
token / asset IDs | Нет | PING каждые 10 с |
| CLOB user | wss://ws-subscriptions-clob.polymarket.com/ws/user |
condition IDs | L2 API credentials | PING каждые 10 с |
| RTDS | wss://ws-live-data.polymarket.com |
topic + type + filters | Обычно публичный; отдельные потоки могут использовать gamma_auth |
PING каждые 5 с |
Текущий market channel — публичный Level 2 поток для стакана, цен и исполнений. User channel передаёт события, связанные с ордерами и сделками конкретных API credentials. RTDS использует другую структуру сообщений; в актуальной документации для него описаны криптовалютные цены, Chainlink-цены, котировки акций и комментарии.
После запуска CLOB V2 28 апреля 2026 года особенно опасно копировать старые примеры целиком. Production CLOB по-прежнему работает на clob.polymarket.com, но V1 SDK и V1-signed orders больше не поддерживаются. Старый пример WebSocket может содержать узнаваемый адрес, но устаревшую обвязку авторизации или торговли.
Есть и принципиальная разница в идентификаторах. Публичный market channel получает assets_ids отдельных outcome tokens. User channel фильтруется по markets, то есть condition IDs. Это разные сущности.
Подписка на market channel
Публичный market channel нужен для потока стакана, лучших цен, изменений уровней и исполнений по конкретным outcome tokens. Кошелёк или API key для него не нужен.
Минимальная подписка:
{
"type": "market",
"assets_ids": [
"TOKEN_ID_YES",
"TOKEN_ID_NO"
],
"custom_feature_enabled": true
}
custom_feature_enabled необязателен. Он добавляет события best_bid_ask, new_market и market_resolved; базовые события стакана работают и без него.
Список токенов можно менять без переподключения, отправляя operation: "subscribe" или operation: "unsubscribe".
Основные события имеют разную семантику:
| Event | Что означает | Что делать клиенту |
|---|---|---|
book |
Полный агрегированный snapshot | Полностью заменить локальное состояние актива |
price_change |
Изменились ценовые уровни после постановки или отмены ордера | Применить изменения уровней |
last_trade_price |
Произошло исполнение | Сохранить сделку, но не считать событие новым стаканом |
tick_size_change |
Изменился минимальный шаг цены | Обновить валидацию новых ордеров |
best_bid_ask |
Изменился top of book | Использовать как дополнительный быстрый сигнал |
В price_change размер "0" означает удаление ценового уровня. Надёжнее хранить локальный стакан как структуру price → size и самостоятельно вычислять лучший bid/ask, а не строить логику на предполагаемом порядке элементов входного массива.
Публичное событие исполнения называется last_trade_price. В нём есть asset_id, market, цена, размер, сторона и timestamp. Его не нужно путать с trade из user channel: там речь уже идёт о жизненном цикле вашей собственной сделки.
Каждые 10 секунд клиент должен отправлять текст:
PING
и получать PONG. Это heartbeat на уровне протокола Polymarket, а не то же самое, что WebSocket ping frame, который библиотека может отправлять автоматически.
Authenticated user channel
User socket нужен для собственных ордеров и исполнений. Авторизация выполняется L2 credentials: apiKey, secret и passphrase. Приватный ключ кошелька в сам WebSocket не отправляется.
L2 credentials создаются или восстанавливаются через L1 authentication, где владение кошельком подтверждается EIP-712 подписью. Секреты нельзя отправлять в frontend или коммитить в репозиторий.
Пример подписки:
{
"type": "user",
"markets": [
"0xCONDITION_ID"
],
"auth": {
"apiKey": "YOUR_API_KEY",
"secret": "YOUR_API_SECRET",
"passphrase": "YOUR_PASSPHRASE"
}
}
Здесь markets — condition IDs. На публичном market socket используются другие идентификаторы — assets_ids.
User channel передаёт две основные группы событий.
order сообщает о постановке, обновлении после частичного исполнения и отмене. Типы включают PLACEMENT, UPDATE и CANCELLATION.
trade показывает дальнейшее состояние исполнения:
MATCHED → MINED → CONFIRMED
↓ ↑
RETRYING ───┘
↓
FAILED
Поэтому MATCHED ещё не означает окончательную финальность. В документированной модели терминальными состояниями являются CONFIRMED и FAILED.
Отдельно стоит логировать проблемы авторизации. В issue официального клиента в феврале 2026 года представитель Polymarket указывал, что при неуспешной auth-проверке сервер мог молча закрыть соединение. Если user socket закрывается сразу после подключения, сначала перепроверьте актуальные credentials, а уже затем ищите проблемы в обработчике событий.
RTDS работает по другим правилам
RTDS подключается к:
wss://ws-live-data.polymarket.com
Там нет подписки через CLOB token ID или condition ID. Клиент отправляет action и список topic subscriptions:
{
"action": "subscribe",
"subscriptions": [
{
"topic": "crypto_prices",
"type": "update",
"filters": "btcusdt,ethusdt"
}
]
}
В текущей документации описаны Binance crypto prices, Chainlink crypto prices, equity prices и comments.
У Binance символы выглядят как btcusdt, а у Chainlink — как btc/usd; формат фильтра тоже различается. Поэтому один и тот же parser подписок без учёта source может корректно подключиться, но не получать ожидаемые данные.
Сообщение RTDS имеет общую оболочку:
{
"topic": "crypto_prices",
"type": "update",
"timestamp": 1753314088421,
"payload": {
"symbol": "btcusdt",
"timestamp": 1753314088395,
"value": 67234.50
}
}
Здесь PING нужно отправлять уже каждые 5 секунд. Подписки можно добавлять и удалять на открытом соединении.
При этом успешный heartbeat не доказывает, что данные продолжают идти. Поэтому мониторить стоит отдельно время последнего PONG и время последнего полезного сообщения.
Минимальный набор метрик:
connection_age_seconds
last_pong_age_seconds
last_data_age_seconds
messages_received_total{topic,type}
reconnect_total{reason}
parse_error_total{topic,type}
Порог stale data зависит от потока. Для BTC price feed и редких комментариев нельзя использовать одно и то же значение.
Когда нужен stream, а когда REST
WebSocket нужен для непрерывного состояния. REST лучше подходит для discovery, единичных snapshot и диагностики.
Практическое разделение:
| Задача | Источник |
|---|---|
| Найти рынки и получить metadata | Gamma / market REST API |
| Один текущий стакан | CLOB REST /book или /books |
| Постоянно поддерживать стакан | CLOB market WebSocket |
| Отслеживать собственные orders/fills | CLOB user WebSocket |
| Получать внешние crypto/equity prices | RTDS |
| Получать comments | RTDS |
| Читать исторические цены | CLOB REST price history |
Постоянно опрашивать /book вместо WebSocket невыгодно логически: после первого snapshot большая часть изменений книги уже приходит как более компактные события уровней.
Обратная крайность тоже не нужна. Market discovery и статические идентификаторы не становятся потоковой задачей только потому, что ваш основной процесс уже держит открытый WebSocket.
Как восстановиться после разрыва и не испортить стакан
Недостаточно просто открыть новое соединение. После разрыва нужно определить, какое локальное состояние всё ещё можно считать действительным.
В текущих документированных market payload есть timestamp и hash, но нет универсального монотонного sequence number для восстановления неизвестного пропуска. Timestamp нельзя самостоятельно превращать в такой sequence.
Безопасная схема для CLOB:
- При потере соединения пометить все книги этого socket как несинхронизированные.
- Переподключиться с backoff и jitter.
- Повторно подписаться на те же token IDs.
- Не применять
price_changeдля актива, пока не пришёл новый полныйbook. - Полностью заменить старое локальное состояние новым snapshot.
- Только после этого снова применять дельты.
- REST
/bookиспользовать для диагностики или fallback snapshot, а не для слепого слияния с неизвестным промежутком WebSocket.
Polymarket документирует book как полный snapshot, приходящий при первоначальной подписке, поэтому именно он является удобной точкой новой синхронизации.
Пример клиента:
import asyncio
import json
import random
from decimal import Decimal
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed
URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
ASSET_IDS = ["TOKEN_ID_YES", "TOKEN_ID_NO"]
class Books:
def __init__(self):
self.data = {}
self.synced = set()
def invalidate_all(self):
self.synced.clear()
def apply(self, msg):
event = msg.get("event_type")
if event == "book":
asset = msg["asset_id"]
self.data[asset] = {
"bids": {
Decimal(x["price"]): Decimal(x["size"])
for x in msg["bids"]
},
"asks": {
Decimal(x["price"]): Decimal(x["size"])
for x in msg["asks"]
},
}
self.synced.add(asset)
return
if event != "price_change":
return
for change in msg["price_changes"]:
asset = change["asset_id"]
if asset not in self.synced:
continue
side = "bids" if change["side"] == "BUY" else "asks"
price = Decimal(change["price"])
size = Decimal(change["size"])
levels = self.data[asset][side]
if size == 0:
levels.pop(price, None)
else:
levels[price] = size
async def heartbeat(ws):
while True:
await asyncio.sleep(10)
await ws.send("PING")
async def stream():
books = Books()
backoff = 1.0
while True:
try:
async with connect(
URL,
ping_interval=None,
open_timeout=10,
close_timeout=5,
) as ws:
books.invalidate_all()
await ws.send(json.dumps({
"type": "market",
"assets_ids": ASSET_IDS,
}))
ping_task = asyncio.create_task(heartbeat(ws))
try:
async for raw in ws:
if raw == "PONG":
continue
books.apply(json.loads(raw))
finally:
ping_task.cancel()
backoff = 1.0
except (ConnectionClosed, OSError, TimeoutError, ValueError):
books.invalidate_all()
await asyncio.sleep(random.uniform(0, backoff))
backoff = min(backoff * 2, 30)
asyncio.run(stream())
Главное здесь — состояние synced. Дельта не применяется к старой книге после разрыва: сначала требуется новый полный snapshot. Decimal используется для ценовых уровней, чтобы не вносить ошибки бинарного float.
Пример намеренно не реализует постоянное хранилище, экспорт метрик, graceful shutdown, RTDS, user channel и REST fallback. Эти части нужно добавить в сервис, который должен жить месяцами.
Для RTDS логика reconnect похожа, но вместо token IDs нужно восстановить topics и filters, heartbeat идёт каждые пять секунд, а freshness данных контролируется отдельно от PONG.
Сначала исправляйте именно эту программную часть: heartbeat, восстановление подписок, invalidation старого state и data watchdog. Когда несколько независимых долгоживущих collectors дополнительно требуют постоянный egress и разделение сетевых точек отказа, персональные прокси дают стабильный адрес для этого слоя. Прокси не исправляют устаревший стакан, неверную auth или потерянную подписку.