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

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

- Источник: https://papaproxy.net/ru/blog/polymarket-websocket-api.php
- Опубликовано: 2026-08-07
- Автор: Alex Young
- Рубрика: API и данные · Блог PapaProxy.net

---

## Ключевые выводы

- 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Копировать код

```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Копировать код

```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Копировать код

```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Копировать код

```text
MATCHED → MINED → CONFIRMED
    ↓        ↑
RETRYING ───┘
    ↓
  FAILED
```

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

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

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

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

TextКопировать код

```text
wss://ws-live-data.polymarket.com
```

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

JSONКопировать код

```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Копировать код

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

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

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

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

TextКопировать код

```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Копировать код

```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 и разделение сетевых точек отказа, [персональные прокси](/ru/individual.php) дают стабильный адрес для этого слоя. Прокси не исправляют устаревший стакан, неверную auth или потерянную подписку.
