Skip to content

Polymarket CLOB — контракт ответов на заявку ​

Canonical reference. Что биржа отвечает на размещение заявки, чем отказ отличается от приёма и какие состояния существуют между ними. Любой FSD, ADR или код, рассуждающий об исходе POST /order, ссылается сюда вместо собственных догадок.

Документ описывает КОНТРАГЕНТА, а не наш движок. Наши правила обработки этих ответов — target/engine.md §«Три исхода отправки». Лимиты частоты — operations/rate-limits.md.

Источники ​

  • https://docs.polymarket.com/api-spec/clob-openapi.yaml — машинная спецификация, скачана 2026-07-29, разобрана целиком.
  • https://docs.polymarket.com/concepts/order-lifecycle.md — жизненный цикл заявки, прочитан 2026-07-29.
  • https://docs.polymarket.com/api-reference/trade/get-user-orders — форма и пагинация GET /data/orders, прочитана 2026-08-23.
  • https://docs.polymarket.com/api-reference/rate-limits — лимит GET /data/orders, прочитан 2026-08-23.
  • Измерения с боевого узла 2026-07-29 (только чтение) — помечены явно.

1. Ответ 200 НЕ означает приёма заявки ​

POST /order отвечает 200 и на отказ. Схема SendOrderResponse несёт success, orderID, status (live / matched / delayed), makingAmount, takingAmount и errorMsg («Error message (empty on success)»). Обязательны только три поля: success, orderID, status. Отдельная схема ErrorResponse (обязательное поле error) приходит только с 4xx/5xx.

1.1 Поле success оракулом НЕ является ​

В самой спецификации есть оба контрпримера:

Пример спецификацииsuccessorderIDstatuserrorMsg
отказ ограничителя частотыfalse''delayedRate limit exceeded for tokenId: …
post_only_modetrue''''post-only mode: only post-only orders and cancels are allowed

То есть success: true приходит НА ОТКАЗ, а success: false — на другой отказ. Читать это поле как ответ на вопрос «приняли ли заявку» нельзя.

1.2 Признак отказа — непустой errorMsg ​

errorMsg не входит в required, поэтому «отсутствует» и «пустая строка» равнозначны и означают отсутствие отказа. Обратное правило («поле присутствует ⇒ отказ») сломало бы любой путь размещения — и это не умозрительно: [БОЙ, 2026-07-29] на УСПЕШНОМ ответе одиночного POST /order поле errorMsg ПРИСУТСТВУЕТ и пусто. Прежняя формулировка этого документа («успешные ответы в бою приходят вовсе без этого поля») была догадкой и здесь снята; правило «отсутствует ≡ пусто» от измерения не меняется.

1.3 Пустая строка в orderID — отсутствие ключа, а не ключ ​

Документированная форма отказа несёт ровно orderID: ''. Разбор, принимающий пустую строку за идентификатор, регистрирует корреляцию по пустому ключу и заводит живую строку ордера без ордера на бирже.

1.4 На одиночном эндпойнте отказ приходит НЕ двухсоткой — измерено ​

[БОЙ, 2026-07-29] Одиночный POST /order отказывает отрицательным HTTP-статусом с телом формы ErrorResponse:

json
{"error":"invalid post-only order: order crosses book"}

Поле называется error, а не errorMsg. Второй измеренный образец того же класса — рутинный FAK-kill, и он НЕСЁТ идентификатор заявки:

json
{"error":"no orders found to match with FAK order...","orderID":"0xb12419e8…"}

Форма «200 плюс непустой errorMsg» задокументирована у ПАКЕТНОГО /orders, которым pm-engine не пользуется; у одиночного эндпойнта все примеры 200 показывают errorMsg: ''.

Практическое следствие для нас: разбор тела 200-ответа (interpret_send_order_response) СОХРАНЯЕТСЯ как оборонительный — схема тела у обоих эндпойнтов одна (SendOrderResponse), цена ошибки разбора это забукированный успех по отказу, а стоимость защиты нулевая. Но утверждать, что 200-с-отказом на одиночном эндпойнте ВСТРЕЧАЕТСЯ, нельзя: живая проба показала другое. Прежняя формулировка этого раздела («одиночный эндпойнт разбирается по тому же правилу», «живого 200-с-отказом мы не видели — это ограничение улик») здесь заменена измерением.

2. Состояние delayed: заявка ПРИНЯТА, судьба ещё не наступила ​

delayed — «marketable order accepted into an asynchronous delay window on configured seconds-delay markets, such as sports markets». Пример одиночного эндпойнта показывает delayed ВМЕСТЕ с непустым orderID, то есть это приём с ключом, а не отказ.

Следствия, важные для любого, кто спрашивает биржу «а есть ли мой ордер»:

  • Заявку в окне задержки нельзя отменить («During either delay, the order is pending and cannot be canceled»; тот же запрет повторён в разделе Cancellation). Спецификация OpenAPI об этом молчит — источник только документация жизненного цикла.
  • Принятая заявка может кончиться отказом: «If the market, balance, allowance, or risk checks fail when the delay expires, the order is rejected instead of matching».
  • Пока заявка delayed, её нет ни в книге, ни в ленте сделок, ни на цепочке. Молчание всех трёх поверхностей в этом окне — НЕ доказательство отсутствия заявки.

2.1 Две РАЗНЫЕ задержки, не путать ​

ЗадержкаГдеВеличинаВиден ли снаружи
Тейкерская (itode: true)избранные крипто- и финансовые рынки up/down250 мс, синхронно ВНУТРИ запроса — «The API waits for this hold and returns the final order result»нет: ответ уже окончательный
Рыночная (seconds_delay)настроенные спортивные рынкисвойство рынка, целое число секундда: ответ delayed

Проверка тейкерской задержки по рынку — публичный GET https://clob.polymarket.com/clob-markets/{condition_id}, поле itode (поле опускается, когда false).

2.2 seconds_delay — измеренное распределение ​

Поле Market.seconds_delay (целое) живёт на поверхности CLOB GET /markets. Обход с боевого узла 2026-07-29 (курсорная пагинация, только чтение, 400 000 строк, обход прерван на 400-й странице — корпус больше):

seconds_delayвсего рынковиз них accepting_orders = true
0231 38813 761
1555
2470
3168 4931 578
450
570
640
3310

Максимум в просмотренной части корпуса — не менее 33 секунд, и это спортивный рынок (0xd3be0d21…, «Will the Guardians and Yankees combine for 8 or more runs…»). Обход прерван на 400-й странице, поэтому 33 — НИЖНЯЯ граница максимума, а не сам максимум: полного обхода корпуса не делалось. Значения 4/5/6 — тоже спорт и киберспорт (NFL, CS2, League of Legends). На момент замера ни один рынок с задержкой больше 3 с заявок не принимал, но домен этих рынков — ровно тот, в котором работает активная стратегия. Считать 3 с потолком нельзя: это максимум по ТОРГУЕМОМУ ПОДМНОЖЕСТВУ в один момент времени, а не свойство биржи.

Где величина живёт и откуда её брать. У Gamma поля нет вовсе (проверено обращением к живому gamma-api.polymarket.com 2026-07-29 — в описании рынка нет ни secondsDelay, ни любого другого ключа с этим смыслом); pm_common::models::market::Market его не имеет; ClobMarketDetails (GET /clob-markets/{cid}) несёт itode и oas, но не seconds_delay. Единственный источник — публичный GET https://clob.polymarket.com/markets/{condition_id}, подписи не требует.

Движок читает его через pm_sdk::clob::public_market::fetch_market_seconds_delay (кэш по рынку, кэшируются только успехи) и выводит из величины тихий период перед отрицательным выводом о судьбе неопределённой отправки — target/engine.md §«Сверка неопределённой отправки». Прежняя запись этого раздела («величина НЕ проведена ни в одну модель движка») устарела и здесь снята.

Разбор описания рынка обязан чистить управляющие символы. [БОЙ, 2026-07-29] В теле GET /markets/{cid} встречаются НЕэкранированные управляющие символы, и строгий JSON-разбор на них падает. Падение прочиталось бы как «задержка неизвестна» по рынку, чьё описание биржа отдала успешно.

Отсутствие поля — не ноль. Кто опирается на задержку конкретного рынка, обязан трактовать её отсутствие как НЕИЗВЕСТНОСТЬ и отказываться от вывода, который на ней стоял, а не подставлять ни ноль, ни константу «по общему максимуму»: верхняя граница задержки по корпусу не установлена вовсе (обход прерван), то есть догадкой была бы любая константа.

3. Словарь состояний заявки шире, чем перечисление в спецификации ​

Документация жизненного цикла перечисляет ЧЕТЫРЕ состояния, спецификация OpenAPI — три (live / matched / delayed).

СостояниеСмыслЕсть в OpenAPI
liveзаявка покоится в книгеда
matchedзаявка исполнилась сразуда
delayedпринята в асинхронное окно задержкида
unmatchedзаявка положена в книгу после того, как окно задержки истекло без сопоставлениянет

Практическое следствие для нас: interpret_send_order_response поля status НЕ читает вовсе (исход выводится из orderID + errorMsg), поэтому расхождение словарей сегодня нас не задевает. Задело бы разбор, закрытый на трёх известных значениях, — такой встретит unmatched в бою.

4. deferExec — поле запроса, которое мы не отправляем ​

В теле POST /order есть булево deferExec («Whether to defer execution», умолчание false). В собираемом нами теле (build_v2_post_body, crates/pm-sdk/src/clob/wire.rs) его нет вовсе, то есть действует умолчание биржи. Отдельного разбора исхода не требует.

5. Состояния сервиса, приходящие статусом 503 ​

Спецификация документирует три тела 503, и все три означают, что новых заявок биржа не принимает вовсе:

  • «Trading is currently disabled»;
  • «Trading is currently cancel-only»;
  • окно post-only-режима (принимаются только post-only-заявки и отмены).

Разграничитель — тело формы ErrorResponse (обязательное поле error), а не сам статус: 503 без такого тела приходит и от промежуточного узла, и тогда запрос мог дойти до биржи. Отмена работает и в cancel-only-режиме — это сказано у всех четырёх эндпойнтов отмены («Works even in cancel-only mode»).

6. Идентификатор заявки численно равен подписному хешу ​

orderID — «Unique identifier for the order (order hash)», то есть EIP-712-хеш подписанной структуры.

[БОЙ, 2026-07-29] Проверено ПОБАЙТОВО, а не выведено. Локально вычисленный подписной хеш 0xba953f0e…bebfaf6 совпал с orderID, который вернула биржа: 0xba953f0e…bebfaf6. Прежняя опора была косвенной (совпадение polymarket_order_id строки базы с topics[1] события OrderFilled в квитанции транзакции) и остаётся верной; теперь равенство измерено напрямую на ответе самой биржи.

Практические следствия: ключ, вычисленный ЛОКАЛЬНО ДО отправки, годится и для отмены (DELETE /order), и для соединения с лентой сделок, и для опознания собственной заявки в перечне открытых ордеров — биржевого ответа для этого не нужно. То есть судьбу заявки можно узнать по хешу, вычисленному до того, как запрос покинул процесс.

7. Лента сделок GET /data/trades — форма, которую легко прочитать неверно ​

Аутентифицированный (L2) эндпойнт, «Retrieves trades for the authenticated user. Returns paginated results». Проверено на боевом кошельке 2026-07-29 (только чтение).

Подпись. Подписывается метка_времени + МЕТОД + путь, строка запроса в подпись НЕ входит. Секрет — base64url; ключ HMAC-SHA256 берётся из ДЕКОДИРОВАННЫХ байтов, подпись возвращается base64url. Заголовки: POLY_ADDRESS, POLY_SIGNATURE, POLY_TIMESTAMP, POLY_API_KEY, POLY_PASSPHRASE.

Пагинация. Ответ — объект {limit, count, next_cursor, data}. Измерено: limit = 300 (умолчание сервера), next_cursor = "LTE=" (base64 от -1) — признак последней страницы; курсор — СМЕЩЕНИЕ в выборке (next_cursor=MTAw, то есть 100, отдал остаток из 6 записей при 106 всего). Разбиение окна на две половины дало 56 + 50 = 106 — усечения нет.

Границы окна. after / before — unix-СЕКУНДЫ. Граница исключающая: филл с match_time, точно равным after, в ответ не попадает (измерено: без запаса нашлось 90 из 91 заведомо налитых локально ордеров, с запасом — 91 из 91).

Чей идентификатор в записи. Запись несёт ОБЕ стороны сделки. Своя сторона определяется полем trader_side (MAKER / TAKER):

  • TAKER ⇒ наш идентификатор — taker_order_id;
  • MAKER ⇒ наш идентификатор лежит в maker_orders[], в записи с нашим maker_address; верхнеуровневые taker_order_id и maker_address принадлежат КОНТРАГЕНТУ.

Верхнеуровневое side — сторона тейкера, не наша. Взять оба конца без разбора значит объявить чужие ордера своими: на боевом окне 2026-07-29 это превращало 0 находок в 418.

Статусы сделок (документация жизненного цикла): MATCHED → MINED → CONFIRMED (терминальный, успех); RETRYING → FAILED (терминальный, неуспех). Запись со статусом RETRYING/FAILED доказывает, что биржа заявку ПРИНЯЛА (несуществующая заявка не сопоставляется), но НЕ доказывает состоявшегося исполнения.

8. Открытые заявки GET /data/orders — только полный снимок ​

Аутентифицированный L2-эндпойнт возвращает объект {limit, count, next_cursor, data}. Все четыре поля обязательны; next_cursor — base64-смещение, а пустая строка означает конец. Измеренный терминальный курсор CLOB "LTE=" (base64 от -1) также завершает обход. Параметры market и asset_id фильтруют по condition ID и token ID.

Официальный Cloudflare IP-лимит для этого эндпойнта — 500 запросов за 10 секунд. Это ledger-read лимит, не per-signer token bucket операций создания и отмены ордеров. Заголовок Poly-RateLimit-Warning относится к последним и не является сигналом для GET /data/orders.

Смысл для движка описан в единственном месте — target/engine.md §«Lifecycle + bootstrap»: ответ нельзя считать снимком, пока не получен терминальный cursor. Этот раздел фиксирует только внешний контракт CLOB и не дублирует политику повторов.

9. Что этим документом НЕ установлено ​

  • Живой 200-ответ с непустым errorMsg на ОДИНОЧНОМ /order не наблюдался, а измеренная форма отказа этого эндпойнта — не-2xx с телом {"error": …} (см. §1.4). Наш разбор тела 200-ответа поэтому оборонительный, а не описание наблюдаемого поведения.
  • Форма «ключ есть И формулировка отказа есть одновременно» в примерах спецификации отсутствует; она допустима по схеме и трактуется нами консервативно, но улик её появления в бою нет.
  • Резервирует ли заявка баланс ВНУТРИ окна задержки — не установлено. Попытка измерить (2026-07-29) не удалась: на рынке с seconds_delay = 1 заявка исполнилась раньше, чем окно удалось наблюдать. Отрицательный результат неинформативен.
  • Верхняя граница seconds_delay по корпусу — обход прерван, 33 с это нижняя граница максимума (§2.2).
  • Область действия ключа доступа (per-IP или per-API-key) биржей не задокументирована — см. operations/rate-limits.md.

10. Резерв долей под живой заявкой виден ТОЛЬКО в тексте отказа ​

[БОЙ, 2026-07-29] Две измеренные половины, которые надо читать вместе.

GET /balance-allowance резерва под открытой заявкой НЕ показывает: баланс долей не меняется ни сразу после выставления продажи, ни спустя три секунды, ни после её снятия. То есть узнать «сколько долей связано живыми заявками» этим эндпойнтом нельзя.

Единственный канал этой информации — ТЕКСТ отказа при попытке выставить вторую заявку на те же доли:

json
{"error":"not enough balance / allowance: the balance is not enough -> balance: 5000000, sum of active orders: 5000000, sum of matched orders: 0, order amount (inc. fees): 5000000"}

Отказ несёт РАЗБИВКУ: сколько долей есть, сколько занято АКТИВНЫМИ заявками, сколько ИСПОЛНЕНО. Машиночитаемого кода у этого отказа нет (ErrorResponse.code описан как «when provided», и единственный код во всём описании — post_only_mode), поэтому разбор строки — не небрежность, а единственный доступный способ.

Границы, без которых разбивку прочтут неверно: три числа — АГРЕГАТЫ по кошельку и исходу, а не по конкретной заявке; «занято активными» может держать посторонняя покоящаяся заявка того же исхода; заявка FAK долей не связывает по построению, поэтому для неё ненулевое «занято активными» относится к чужому ордеру всегда.