Тема
Обнаружение событий и рынков
Статус: целевой нормативный контракт. Реализация обязана быть приведена к нему отдельной работой и не считается соответствующей только потому, что отдельные типы или запросы уже существуют.
Официальный внешний контракт: Polymarket — List events (keyset pagination) и List markets (keyset pagination).
Назначение и граница
discovery — единственная подсистема, которая обнаруживает события и рынки, поддерживает их фактическое состояние и передаёт одни и те же данные нескольким движкам стратегий. Она не принимает торговых решений и не применяет фильтры допуска конкретной стратегии к каноническим данным.
EventRevision является неизменяемым корнем сквозного EventContext. Discovery публикует контекст стадии Discovered; последующие модули обогащают его новыми типизированными результатами, не копируя и не изменяя фактическую ревизию события.
Единица обнаружения, хранения, публикации и будущего шардирования — событие Gamma с ключом event_id. event_slug является отображаемым атрибутом и не может использоваться как ключ шардирования. condition_id используется как индекс рынка внутри события, но не как запасной ключ события: неизвестная принадлежность рынку к событию означает незавершённое обнаружение, а не право поместить рынок в отдельный шард.
text
Gamma /events/keyset ─┐
Gamma /markets/keyset ├──▶ discovery ──▶ EventRevisionStore
Market WS lifecycle ─┘ │
├──▶ StrategyUniverse A
├──▶ StrategyUniverse B
├──▶ shard(event_id)
└──▶ shared publisherГраницы модулей
Клиенты pm-sdk
GammaClient— единственный HTTP-клиент Gamma. Он владеет базовым URL, путями endpoint'ов, тайм-аутом, общим ограничителем частоты всех Gamma-запросов, отправкой, чтением тела, десериализацией и типизированными ошибками.- Все вызовы
/events/keysetи/markets/keysetиспользуют один и тот жеArc<GammaClient>и общий бюджет запросов. Создание отдельного HTTP-клиента или отдельного ограничителя внутриdiscoveryзапрещено. MarketWsPoolостаётся единым WS-клиентом. Один раз разобранные кадры разделяются по типизированным темам черезArc, как определено в event-context.md. Модульdiscoveryвладеет одной lifecycle-подпиской; композиционный корень не создаёт отдельные подписки для листинга и закрытия.- Клиенты отражают внешний протокол и не содержат конфигурацию, фильтры допуска или решения конкретной стратегии.
Модуль pm-engine::adapters::market_data::discovery
text
discovery/
├── mod.rs — публичная граница адаптера
├── runtime.rs — таймеры, одна WS-подписка, остановка задач
├── coverage.rs — профили покрытия и общий план запросов
├── events.rs — полные проходы /events/keyset
├── markets.rs — /markets/keyset по известным condition_ids
├── lifecycle.rs — маршрутизация NewMarket и MarketResolved
├── listing.rs — пакетная гидратация новых рынков
├── closure.rs — немедленное закрытие и REST-сверка
├── model.rs — EventRevision и MarketFact
├── store.rs — атомарные ревизии и индексы
└── projection.rs — формирование представления для потребителяРазбиение на файлы может меняться без изменения архитектуры. Нормативны ответственности и направления зависимостей: транспорт находится в pm-sdk, фактическая модель и координация источников — в discovery, торговая политика — в движке стратегии.
Каноническая фактическая модель
rust
struct EventRevision {
event_id: EventId,
event_slug: String,
revision: u64,
observed_at: UnixMillis,
markets: Arc<[MarketFact]>,
}EventRevision содержит полный упорядоченный массив Event.markets[] до локальных фильтров рынка, окна входа и политики стратегии. Ревизия публикуется атомарно: потребитель видит либо предыдущую целую ревизию, либо следующую целую ревизию. Частично разобранное событие не становится текущим.
MarketFact содержит как минимум condition_id, токены, признаки жизненного цикла, названия, market slug, даты, ликвидность, объём и поля, необходимые действующим потребителям. MarketMetadata — производное представление для движка, а не исходная модель discovery.
Хранилище поддерживает индексы:
event_id → Arc<EventRevision>;condition_id → event_id;token_id → (event_id, condition_id);coverage_id → множество event_id.
Контракт GET /events/keyset
Транспорт и пагинация
Параметры транспорта не принадлежат стратегии:
| Параметр Gamma | Тип | Требование системы |
|---|---|---|
limit | integer | 1..=500; значение выбирает клиент или планировщик, без скрытого значения вне контракта |
order | string | список имён JSON-полей; одно значение сохраняется на всём проходе |
ascending | boolean | действует только вместе с order; отдельное значение без order отвергается как неработающая настройка |
after_cursor | string | непрозрачен; только сборщик страниц подставляет курсор предыдущего ответа |
offset | integer | запрещён и не представлен в типе запроса; сервер документированно отвечает 422 |
Все поля фильтра, порядок и направление сортировки остаются неизменными на протяжении одного прохода. Между страницами меняется только after_cursor. Конец определяется только отсутствием next_cursor. Размер страницы не является признаком конца. Пустой или повторный курсор означает отсутствие прогресса и отменяет весь проход. Произвольного потолка страниц нет.
Ошибка любой страницы отменяет весь проход; накопленный префикс не публикуется. Документированные ответы 422, 500 и 503, а также транспортная ошибка сохраняются как разные типизированные причины со статусом и полным телом ответа.
Фильтры покрытия
Контракт запроса обязан поддерживать все документированные фильтры endpoint'а, а не только tag_id:
| Группа | Параметр Gamma | Тип wire | Локальный тип и правило |
|---|---|---|---|
| Идентичность | id | integer[] | Vec<GammaEventId> |
| Идентичность | slug | string[] | Vec<EventSlug>; это фильтр события, не ключ шардирования |
| Состояние | closed | boolean | Option<bool> |
| Состояние | live | boolean | Option<bool>; не подменяет отсутствующий в keyset параметр active |
| Состояние | featured | boolean | Option<bool> |
| Состояние | cyom | boolean | Option<bool> |
| Текст | title_search | string | серверный поиск события по вхождению подстроки; строка передаётся без локального изменения регистра или разделителей |
| Ликвидность | liquidity_min | number | Option<Decimal> |
| Ликвидность | liquidity_max | number | Option<Decimal> |
| Объём | volume_min | number | Option<Decimal> |
| Объём | volume_max | number | Option<Decimal> |
| Даты | start_date_min | date-time | Option<UtcDateTime> |
| Даты | start_date_max | date-time | Option<UtcDateTime> |
| Даты | end_date_min | date-time | Option<UtcDateTime> |
| Даты | end_date_max | date-time | Option<UtcDateTime> |
| Даты | start_time_min | date-time | Option<UtcDateTime> |
| Даты | start_time_max | date-time | Option<UtcDateTime> |
| Теги | tag_id | integer[] | Vec<TagId> |
| Теги | tag_slug | string | Option<TagSlug> |
| Теги | exclude_tag_id | integer[] | Vec<TagId>; пересечение с tag_id запрещено внешним контрактом и отвергается до запроса |
| Теги | related_tags | boolean | Option<bool> |
| Теги | tag_match | string | непрозрачное значение Gamma; система передаёт его дословно и не придумывает локальный enum без подтверждённого списка значений |
| Серия | series_id | integer[] | Vec<SeriesId> |
| Игра | game_id | integer[] | Vec<GameId> |
| Календарь | event_date | date-time | Option<UtcDateTime> |
| Календарь | event_week | integer | Option<u32> |
| Выдача | featured_order | boolean | Option<bool> |
| Повторение | recurrence | string | непрозрачная строка Gamma, передаваемая дословно |
| Автор | created_by | string[] | Vec<CreatorId> |
| Иерархия | parent_event_id | integer | Option<GammaEventId> |
| Иерархия | include_children | boolean | Option<bool> |
active и archived отсутствуют в официальном контракте /events/keyset и не могут отправляться как скрытые параметры. Если потребителю нужны эти признаки, они проверяются по полям полной фактической ревизии после ответа. live не считается синонимом active.
Пустые и целиком пробельные строки для строковых фильтров отвергаются. Числовые границы ликвидности и объёма обязаны быть конечными и неотрицательными; для пары min/max выполняется min <= max. Временные значения сериализуются как RFC 3339 UTC; для каждой пары границ выполняется min <= max. Относительные выражения вроде «через сутки» не являются частью wire-контракта: до запроса они обязаны быть разрешены через управляемый ClockPort в конкретный UtcDateTime.
Документация Gamma перечисляет tag_match и recurrence как строки, но не задаёт закрытого множества значений. Пока список не подтверждён официальным контрактом или живым проверяемым ответом, клиент хранит их как проверенную на непустоту строку и не приписывает значениям собственную семантику.
Параметры формы ответа
Следующие параметры не определяют торговую пригодность события. Они управляют дополнительными отношениями или локализацией ответа и поэтому отделены от EventDiscoveryFilter:
| Параметр Gamma | Тип | Назначение по официальному контракту |
|---|---|---|
partner_slug | string | добавляет external_partners для совпавших событий |
include_chat | boolean | добавляет Chats и Series.Chats |
include_template | boolean | добавляет Templates |
include_best_lines | boolean | добавляет BestLines |
locale | string | выбирает локаль ответа |
Обычный discovery не запрашивает необязательные отношения, пока каноническая модель не имеет их фактического потребителя. Это предотвращает мёртвые поля и лишний объём ответа. Сам тип клиента обязан уметь выразить параметры, чтобы их будущий потребитель не создавал второй HTTP-путь.
Gamma всегда включает в успешный ответ Series, Tags, Markets и EventCreators. Вложенные рынки содержат clob_rewards и fee_schedule; компонент нормализации выбирает из ответа только поля канонической модели, но не может выбрасывать рынки события до проверки целостности ревизии.
Профили покрытия нескольких стратегий
Каждая стратегия объявляет один или несколько StrategyDiscoveryProfile. Профиль состоит из:
- списка точных
EventDiscoveryFilter, определяющих область запросов Gamma; - локальной
StrategyUniversePolicy, определяющей, какие рынки из полной ревизии стратегия допускает к своей обработке.
Эти уровни не смешиваются. Фильтр Gamma уменьшает область получаемых событий; политика стратегии строит представление над уже полученной полной ревизией. Фильтр одной стратегии никогда не удаляет данные из EventRevisionStore и не влияет на представление другой стратегии.
rust
struct StrategyUniverseRevision {
event: Arc<EventRevision>,
admitted_markets: Vec<ConditionId>,
}Стратегия получает ссылку на общую ревизию и собственный список допущенных рынков. Изменение конфигурации стратегии повторно строит её представление из текущего хранилища и не требует повторного HTTP-запроса, если профиль покрытия источника не изменился.
Планирование общих запросов
- Побайтово одинаковые после типизированной сериализации фильтры разных стратегий выполняются один раз за такт и разделяют результат.
- Различающиеся фильтры выполняются как отдельные keyset-проходы. Нельзя объединять их конкатенацией
tag_id, выбором более широкого диапазона или переносом части условий на клиент: документация не задаёт общую AND/OR- семантику сочетаний, а такая оптимизация может изменить множество событий. - Один профиль может содержать несколько запросов. Их результаты образуют объединение по
event_idтолько для этого профиля. - Общий такт заранее присваивает каждому уникальному запросу стабильный
coverage_idиз полного типизированного запроса. Завершение HTTP в другом порядке не меняет результат: конфликтующие наблюдения одногоevent_idразрешаются по стабильному порядкуcoverage_id, и более поздняя в этом порядке целая строка заменяет предыдущую целиком. - Частичного слияния полей двух строк одного события нет. Производственного счётчика или отдельной бизнес-сущности «дубликат» нет; повтор строки проверяет только корректность пагинации и детерминированность замещения.
- Состав
coverage_id → event_idпубликуется только после полного успешного прохода. Ошибка не превращает неизвестность в пустое множество и не удаляет события из представления стратегии.
WS lifecycle внутри discovery
DiscoveryRuntime сам получает одну lifecycle-подписку от общего WS data plane. Она содержит только два события:
rust
enum LifecycleEvent {
NewMarket { condition_id: ConditionId },
MarketResolved {
condition_id: ConditionId,
winning_token_id: Option<TokenId>,
ts_ms: u64,
},
}Остальные события Market WS принадлежат market_data; они не проходят через внутреннюю шину discovery и не заставляют discovery повторно разбирать тот же кадр.
NewMarketгидратируется пакетным/markets/keysetпоcondition_ids. Официальный ответ markets keyset включает связьEvents; discovery обязан получить из неёevent_idи полную принадлежность рынка. Если связь отсутствует, неоднозначна или ещё не опубликована Gamma, рынок остаётся вPendingEventAssignmentдо полного/events/keyset. Он не публикуется и не шардируется поcondition_id.- После разрешения
event_idновое наблюдение обновляет целую ревизию события, а затем все заинтересованные стратегии повторно строят свои представления. MarketResolvedдля известногоcondition_idнемедленно обновляет состояние рынка и выпускаетClosureEvent, не ожидая HTTP. Закрытие не ставится в одну очередь с медленной гидратацией листинга.- Периодический
/markets/keyset?condition_ids=...&closed=trueостаётся авторитетной сверкой отслеживаемых рынков после пропуска WS, отставания потребителя или переподключения. - Очередь гидратации новых рынков ограничена. Её переполнение может задержать обнаружение до следующего полного
/events/keyset, но не может блокировать чтение WS или потерять закрытие известного рынка.
Шардирование и переиспользование
Полный контракт владения контекстом, ветвления нескольких стратегий и zero-copy передачи между стадиями определён в event-context.md. Этот раздел определяет только discovery-часть: выбор владельца по event_id и публикацию канонической ревизии до разветвления стратегий.
event_id разрешается до допуска рынка и является неизменным ключом назначения на всё время жизни события в обработке. Все рынки и токены одного события попадают в один шард. Изменение event_slug не меняет назначение.
Общая ревизия содержит только факты источника. Каждый движок стратегии владеет своей политикой, позициями, заявками и решениями. В одном процессе он получает Arc<EventRevision> из общего хранилища; в разных процессах shared-market-data-publisher переносит ту же целую ревизию с event_id, порядковым номером, эпохой, временем источника и контролем целостности. Издатель не применяет фильтры стратегии и не создаёт вторую модель события.
Медленный потребитель не создаёт противодавление источнику: он переходит в явное состояние разрыва и восстанавливается полным снимком. Потерянная или частичная ревизия не становится текущей и не выдаётся стратегии за свежие данные.
Проверяемые требования
До признания реализации соответствующей обязательны как минимум:
- контрактный тест сериализации каждого параметра
/events/keysetс точным wire-именем и типом; - отрицательные тесты
offset, пересеченияtag_id/exclude_tag_id, пустых строк, неверных диапазонов,ascendingбезorderи подменыactive/archived; - многостраничные тесты неизменности фильтра, передачи курсора, полной последней страницы без курсора, повторного курсора и ошибки промежуточной страницы;
- тест атомарности
EventRevisionи запрета публикации частичного события; - тест одинаковых профилей двух стратегий с одним HTTP-проходом и разными локальными представлениями;
- тест разных профилей без опасного объединения их фильтров;
- тест
event_id-шардирования всех рынков события и запрета fallback наevent_slugилиcondition_id; - тест
NewMarketбез событийной связи: состояние pending, без допуска и без назначения шарда; - тест, что медленная гидратация листинга не задерживает
MarketResolved; - тест, что WS-потеря восстанавливается полными events/markets keyset-проходами;
- тест, что изменение фильтра одной стратегии не меняет вселенную другой.