Продукты
Безлимитные прокси от $19/мес Ротационные прокси от $49/мес ISP прокси от $33/мес Персональные прокси от $3.50/мес UDP прокси от $5/мес Попробовать прокси
По целям
Парсинг и данные ИИ-сервисы Соцсети и мессенджеры Медиа и развлечения Все задачи →
Цены
Полная таблица цен Все 20 стран Гарантия возврата
Ресурсы
Блог MCP-сервер Инструкции по настройке Частые вопросы Для бизнеса О сервисе Партнёрская программа
Русский
English Русский
← Блог PapaProxy.net

Как подписаться на WebSocket-каналы Polymarket

У Polymarket две разные WebSocket-системы: CLOB передаёт стаканы и события собственных ордеров, а RTDS — внешние цены и комментарии. Для CLOB нужен текстовый `PING` каждые 10 секунд, для RTDS — каждые 5.

API и данные · Автоматизация 7 августа 2026 3 минуты Alex Young Alex Young Технический специалист
Ключевые выводы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 для него не нужен.

Минимальная подписка:

JSON
{
  "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 секунд клиент должен отправлять текст:

Text
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 или коммитить в репозиторий.

Пример подписки:

JSON
{
  "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 показывает дальнейшее состояние исполнения:

Text
MATCHED → MINED → CONFIRMED
    ↓        ↑
RETRYING ───┘
    ↓
  FAILED

Поэтому MATCHED ещё не означает окончательную финальность. В документированной модели терминальными состояниями являются CONFIRMED и FAILED.

Отдельно стоит логировать проблемы авторизации. В issue официального клиента в феврале 2026 года представитель Polymarket указывал, что при неуспешной auth-проверке сервер мог молча закрыть соединение. Если user socket закрывается сразу после подключения, сначала перепроверьте актуальные credentials, а уже затем ищите проблемы в обработчике событий.

RTDS работает по другим правилам

RTDS подключается к:

Text
wss://ws-live-data.polymarket.com

Там нет подписки через CLOB token ID или condition ID. Клиент отправляет action и список topic subscriptions:

JSON
{
  "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 имеет общую оболочку:

JSON
{
  "topic": "crypto_prices",
  "type": "update",
  "timestamp": 1753314088421,
  "payload": {
    "symbol": "btcusdt",
    "timestamp": 1753314088395,
    "value": 67234.50
  }
}

Здесь PING нужно отправлять уже каждые 5 секунд. Подписки можно добавлять и удалять на открытом соединении.

При этом успешный heartbeat не доказывает, что данные продолжают идти. Поэтому мониторить стоит отдельно время последнего PONG и время последнего полезного сообщения.

Минимальный набор метрик:

Text
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:

  1. При потере соединения пометить все книги этого socket как несинхронизированные.
  2. Переподключиться с backoff и jitter.
  3. Повторно подписаться на те же token IDs.
  4. Не применять price_change для актива, пока не пришёл новый полный book.
  5. Полностью заменить старое локальное состояние новым snapshot.
  6. Только после этого снова применять дельты.
  7. REST /book использовать для диагностики или fallback snapshot, а не для слепого слияния с неизвестным промежутком WebSocket.

Polymarket документирует book как полный snapshot, приходящий при первоначальной подписке, поэтому именно он является удобной точкой новой синхронизации.

Пример клиента:

Python
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 или потерянную подписку.