Тема
ADR-DRAFT: Live-price admission + pre-subscription для 5-минутных крипто-рынков (directional/Harvester)
ИСТОРИЧЕСКИЙ DRAFT. Не принят и не является текущим решением. Упоминания
pm-bot,dry-runиSimFillEngineотносятся к удалённой архитектуре; runtime simulation снята 2026-08-22. Для нового решения требуется отдельный актуальный change на factual live/probe telemetry.
Status
Proposed — дизайн на согласование. КОД НЕ МЕНЯЛСЯ. Code-path и активная строка конфига подтверждены прямым чтением VPS (см. «Состояние на VPS»). Остаётся единственное решение пользователя: go/no-go по рекомендованному дизайну.
Контекст
Симптом
Стратегия directional/Harvester в конфигурации 5-минутных крипто-рынков почти никогда не входит в сделки.
Корень — это ДВА связанных дефекта, не один
Оба должны быть устранены вместе; устранение только одного результата не даёт.
Дефект 1 — рынок не подписан на WS Market book.compute_subscription_tokens (crates/pm-bot/src/application/scanner.rs:2513) строит множество подписки как union:
order_manager.orders[*].token_id,- positions из
positions_registry, - (только directional)
pending_orders+ filled-позиции.
Рынок из MarketCache, у которого ещё НЕТ ни ордера, ни позиции, ни pending — не подписывается никогда. Для входа в новый рынок это classic catch-22: чтобы подписаться, нужен ордер; чтобы поставить ордер, нужна свежая цена; чтобы получить свежую цену по WS, нужна подписка.
Дефект 2 — даже event-path принимает решение по УСТАРЕВШЕЙ цене. WS Book handler (scanner.rs:817-948) уже передаёт ЖИВЫЕ top-of-book значения в engine.handle_orderbook_update(cond_id, asset_id, book_bid, book_ask) (scanner.rs:927), которые попадают в OrderbookContext.book_bid/book_ask (engine.rs:257, contexts.rs:189-191).
Но on_orderbook_update (directional.rs:5295) для admission строит MarketInput через market_to_input(market) (directional.rs:5330), который читает market.tokens[i].best_ask/best_bid из MarketCache. А MarketCache.best_ask/bid заполняются ТОЛЬКО из Gamma REST (gamma_client.rs:585-611), refresh каждые 60 с (main.rs), причём apply_prepared полностью заменяет кеш и сбрасывает любые WS-накопленные цены (market_cache.rs:113-115, market_cache.rs:167-177). Живые ctx.book_bid/book_ask используются только для stop-loss и debounce/filter гейтов, но НЕ для evaluate_signal.
Итог: даже если подписать рынок (устранить Дефект 1), admission всё равно решает по Gamma-цене с лагом до 60 с. У 5-минутки коридор входа [0.91, 0.98] живёт секунды — бот системно опаздывает.
Почему это специфично для 5-минуток
entry_window_seconds(VPS строкаharvester-5m) = 180 с — вход в последние 3 минуты до anchor (anchor_source=EndDate). Рынок живёт ~5 минут целиком. Узкое окно лишь усиливает аргумент: одновременно в окне ~4-10 рынков, а цена входит в коридор[0.91, 0.98]примерно в последнюю минуту — 180-секундного lead подписки с запасом хватает, чтобы быть подписанным к моменту открытия окна.- Периодический scan =
BOT_FALLBACK_SCAN_SECS= 30 с (для harvester). - Сумма лагов: до 60 с Gamma + до 30 с scan = до 90 с. Для часовых/дневных рынков (weather/daily, окно входа в часы) этот лаг приемлем; для 5-минуток — фатален.
Состояние на VPS (проверено прямым sqlite3 чтением живого config.db)
Активная строка существует и работает. Артефакт ранней путаницы: локальная копия config.db в репозитории = инстанс harvester-engine (строки harvester / bitcoin-5m / harvester-engine-v2), а боевой 5-минутный бот читает ОТДЕЛЬНЫЙ config.db инстанса harvester-5m на VPS. Реальное состояние VPS:
| label (инстанс) | enabled | схема | tag | anchor | entry_window | вывод |
|---|---|---|---|---|---|---|
harvester-5m (instances/harvester-5m) | 1 | legacy DIRECTIONAL_* | 102892 | EndDate | 180 | 5-мин крипто — АКТИВНАЯ цель |
harvester-weather (instances/harvester-live) | 1 | legacy DIRECTIONAL_* | 103040 | GstLocalMidnight | "" (пусто) | weather daily |
Контейнер pm-bot-harvester-5m (BOT_STRATEGY_LABEL=harvester-5m) при старте залогировал pm-bot starting strategy=Directional dry_run=true admission_min_probability=Some(0.9). То есть цель чинить — именно строка harvester-5m, она enabled, legacy-схема, entry_window_seconds=180 задан → Часть A (pre-subscription по time-gate) применима без изменения конфига.
КЛЮЧЕВОЙ ФАКТ (проверено кодом). Существует ровно ОДНА реализация стратегии: enum StrategyImpl { Directional } (dispatcher.rs:50-52). Загрузчик config.db парсит ТОЛЬКО legacy-ключи (DIRECTIONAL_*, entry_window_seconds, entry_flow). Ключи новой схемы (*_milli, *_micro_usd, блок ldi) НЕ десериализуются нигде в Rust-workspace — строки harvester-engine* это staged pm-engine конфигурация, текущим бинарём не потребляемая. Дизайн корректно целит единственный реальный код-путь — legacy directional.rs DirectFlow, активная строка harvester-5m.
Verified entry_flow
bitcoin-5m и harvester используют EntryFlow::Direct (default, directional.rs:429). WatchArmFak этими строками НЕ используется. Значит admission идёт через evaluate_signal (directional.rs:5383), а не через arm-trigger. Дизайн ниже — для DirectFlow.
Решение (предлагаемое)
Аддитивное, opt-in, без параллельного pipeline. Две части, обе обязательны.
Часть A — Pre-subscription по time-gate (устраняет Дефект 1)
Расширить compute_subscription_tokens (scanner.rs:2513): для directional, когда включён новый knob presubscribe_enabled, добавить в множество подписки токены рынков, прошедших time-gate «до anchor осталось ≤ entry_window_seconds + буфер».
Предикат уже существует: entry_window::evaluate(market, cfg, now) (entry_window.rs) возвращает RefuseTooEarly { until_anchor_secs } / Ok / RefuseTooLate. Кандидат на pre-subscription = рынки, для которых secs_to_anchor ∈ [0, entry_window_seconds + presubscribe_lead_seconds].
Важно — pre-subscription гейтуется на наличии entry_window_seconds. Когда entry_window_seconds отсутствует/пуст (как у строки harvester weather: ""), entry_window::evaluate пропускает too-early проверку — для weather это корректно (окно входа в часы). Поэтому критерий pre-subscription ДОЛЖЕН срабатывать только при entry_window_seconds = Some(w); при None рынок НЕ добавляется в pre-subscription (иначе предикат «всегда в окне» = «подписать всё», что разрушает оценку 7-14 токенов и rate-limit аргумент). Это второй гейт поверх presubscribe_enabled: фича применима только к строкам с заданным конечным окном входа (т.е. 5-минутки), а не к weather/daily. Это переиспользует ту же anchor-логику (AnchorSource), что и admission — никакой второй deadline-арифметики (соответствует feedback_deadline_detection_split: это predictive gate, не closure).
Оценка количества: при окне 180 + lead секунд и 7 активах (BTC/ETH/SOL/XRP/ HYPE/DOGE/BNB) одновременно в окне ~4-10 токенов. Это добавляется к существующей подписке orders/positions. Пренебрежимо для WS-лимитов.
Ротация: rebuild_ws_subscription уже вызывается на fallback-тике (30 с) и после webhook/RTDS (scanner.rs:1182,1459,1665,1892,1948). Diff-логика (scanner.rs:2670-2714) подписывает только новые и отписывает только ушедшие — никакого полного reconnect. Рынок входит в окно → подписка; вышел/закрылся → отписка на следующем rebuild.
Часть B — Live-price admission (устраняет Дефект 2)
В on_orderbook_update (directional.rs:5295), сразу после let input = market_to_input(market) (строка 5330), переопределить top-of-book поля input.best_bid/best_ask живыми значениями ctx.book_bid / ctx.book_ask ДО вызова evaluate_signal (5383). Это единая точка: чинит DirectFlow и (если когда-то включат) WatchArmFak.
Принципиальный выбор источника: admission читает ЖИВУЮ цену из ctx (которая пришла прямо с WS-провода через handle_orderbook_update). MarketCache.best_ask от Gamma остаётся только для вероятности/метаданных и как fallback, когда ctx.book_* пусты (полупустой стакан). Gamma-wipe (market_cache.rs:113) становится нерелевантен ПО ПОСТРОЕНИЮ — admission на него не опирается. Это прямо соответствует ADR-022 («orderbook_store — единственный источник стакана») и feedback_single_data_pipeline (единый pipeline WS→scanner→strategy).
Часть C — Триггер оценки
Event-driven путь делается ПЕРВИЧНЫМ для 5-минуток: подписанный рынок генерирует WS Book событие → on_orderbook_update → live-admission. Периодический scan (evaluate_periodic, 30 с) ОСТАЁТСЯ как backstop для остальных стратегий (weather/daily), где Gamma-лаг приемлем, и НЕ трогается. Это автоматически аддитивно: см. «Совместимость».
evaluate_periodic НЕ получает доступ к живой цене (это потребовало бы плумбинга orderbook_store в StrategyDeps, которого там сейчас нет — contexts.rs:9-44). Для 5-минуток это и не нужно: вход едет на event-path. Если позже понадобится живой periodic — это отдельная задача (Открытый вопрос Q3).
Доказательство аддитивности (no-regression)
on_orderbook_update делает ранний возврат на has_active_entry(condition_id) (directional.rs:5316) ещё ДО построения input. Этот гейт покрывает и удерживаемые, и in-flight позиции. Значит до live-admission доходят ТОЛЬКО токены «подписан, но ещё не активен». Сегодня таких ~ноль (подписка = только orders/positions/pending). С pre-subscription это ровно 5-минутки в окне.
Для weather/daily при выключенном presubscribe_enabled подписка не расширяется за пределы orders/positions → их on_orderbook_update видит только удерживаемые токены → ранний возврат на has_active_entry → байт-идентичное поведение. Это сильнейший аргумент отсутствия регресса.
Альтернативы (по ключевым развилкам)
Развилка 1: источник живой цены для admission
- A (выбрано): admission читает
ctx.book_bid/book_ask(живые с провода) с fallback на Gamma. Плюс: zero-plumbing (ctx уже есть), Gamma-wipe нерелевантен, соответствует ADR-022/single-pipeline. Минус: live-цена доступна только в event-path, не в periodic. - B: вызывать
update_best_prices(market_cache.rs:414) в проде, чтобы WS писал живые цены обратно в MarketCache; admission читает MarketCache как сейчас. Плюс: и periodic, и event видят живую цену единообразно. Минус: борется с 60-с Gamma-wipe (гонка write vs replace), вводит ВТОРОЙ источник цены в кеше — ровно то, против чего предупреждает single-pipeline; сейчас этот метод использовался только снятым бэктестом (2026-08-21), то есть живого потребителя у него нет. - C: отдельный live-price store, читаемый admission напрямую через
OrderbookStore::best_bid_ask(orderbook/store.rs:310). Плюс: чистый единый источник, работает и для periodic. Минус: требует плумбингаorderbook_storeвStrategyDeps(новое поле + правка всех construction sites engine.rs:226-242). Больше LOC, чем A.
Рекомендация: A для текущей задачи (минимальный, принципиально чистый); C — если пользователь захочет, чтобы и periodic ходил по живой цене (тогда merge с Q3).
Развилка 2: критерий pre-subscription
- A (выбрано): time-gate
secs_to_anchor ≤ entry_window + leadчерез существующийentry_window::evaluate. Плюс: переиспользует anchor-логику, узкое множество (~7-14 токенов). Минус: зависит от того, что рынок уже в MarketCache (discovery — отдельный 60-с пол, см. Риски). - B: prob-gate (как WatchArmFak watchlist) — подписывать всё, что прошло
min_probability. Минус: множество шире и нестабильнее, prob-gate не отражает «скоро вход». - C: подписывать ВСЕ активные крипто-рынки тега. Минус: десятки токенов, лишний WS/REST трафик, нарушает принцип узкого множества.
Развилка 3: триггер оценки 5-минуток
- A (выбрано): event-path первичный, periodic — backstop для остальных.
- B: гонять live-admission ещё и в periodic (плумбинг store). Откладывается как Q3.
Риски
- Discovery floor — практически неактуален (проверено live Gamma). Polymarket пре-листит ~1995 будущих 5-минутных слотов на ~24 ч вперёд, все уже
active=true, acceptingOrders=true. То есть рынок, который торгуется СЕЙЧАС, попал в MarketCache часами раньше — 60-с Gamma discovery floor для этих инструментов не кусается. Узкое место — исключительно «увидеть цену вовремя», что и закрывают Части A+B. Ускорение discovery (NewMarket) для текущей задачи не требуется. - WS/REST rate-limit.
rebuild_ws_subscriptionзеркалит подписку вreconciliation_pool(scanner.rs:2693) → REST/bookнагрузка. 7-14 токенов с ротацией раз в 5 минут пренебрежимо мало против лимитов (GET /book 1500/10s; reference_polymarket_rate_limits). Релевантна динамика pool budget, а не сырой лимит. Риск низкий, но назвать явно. - Race при ротации подписки. Между подпиской и первым book-snapshot токен получает price_change до snapshot — уже обрабатывается (drop delta,
scanner.rs:807-814). Свежеподписанный рынок до первого Book не имеет живой цены; admission не сработает до snapshot — это корректно (не вход по пустому). - Согласованность цены.
ctx.book_bid/book_askберутся из top-of-book уже отсортированного стакана (scanner.rs:919-920сканирует все уровни — не first()); риск phantom $0.01 bid уже закрыт (commit 3f224f0, 2026-05-14). - Влияние на другие стратегии. Закрыто доказательством аддитивности выше при
presubscribe_enabled=false. - dry-run / SimFillEngine.
SimFillEngine.check_exitsсрабатывает только наWsEvent::Book(scanner.rs:846; memory project_dry_run_exit_simulation_gap). Pre-subscription увеличивает частоту Book-событий для торгуемых 5-минуток → как побочный эффект частично смягчает gap симуляции выходов в dry-run. Это следствие, не основная цель; полное решение exit-gap — отдельная задача.
Новые config-knobs (typed config, config.db; не хардкод)
Per feedback_no_hardcoded_strategy_params + feedback_strategy_specific_typed_config:
presubscribe_enabled: bool(default false) — opt-in для всей фичи.presubscribe_lead_seconds: u64— буфер сверхentry_window_secondsдля раннего входа рынка в множество подписки. Без default в коде — default в schema-валидаторе.- (опционально)
live_price_admission_enabled: bool— если нужно развязать Часть B от Части A для поэтапного включения. Иначе обе части гейтятся однимpresubscribe_enabled.
Все defaults — в schema-валидаторе фронтенда/config, не в Rust-коде стратегии.
Точки изменения (file:line) и оценка сложности
| Точка | Файл:строка | Изменение |
|---|---|---|
| Подписка | scanner.rs:2513 compute_subscription_tokens | + ветка directional + time-gate; нужен доступ к MarketCache + now в этой функции (сейчас pure, берёт только OM+strategy) — потребует протянуть cache/now |
| Rebuild trigger | scanner.rs:2670 | сигнатура rebuild может потребовать cache-хэндл |
| Live admission | directional.rs:5330-5383 | override input.best_bid/best_ask из ctx.book_* перед evaluate_signal |
| Config struct | directional.rs:~276 (DirectionalConfig) + парсер | + 2-3 knobs |
| Schema/валидатор | frontend strategy-specific + config parse | defaults + типы |
Сложность: средняя. Это ВАЙРИНГ существующей инфраструктуры (resubscribe, orderbook_store/ctx, entry_window-предикат уже есть), не новые подсистемы. Главная неочевидность — compute_subscription_tokens сейчас pure (без cache/now); time-gate требует прокинуть туда &MarketCache + now, что затрагивает её сигнатуру и unit-тесты. Порядок: ~3-5 точек изменения кода + правки тестов. LOC-оценка: десятки строк логики + тесты; без новых крейтов/зависимостей.
Принятые решения (закрыты доказательствами / дефолтами; не требуют опроса)
- Активная строка (бывш. Q1) — ЗАКРЫТО. Цель =
harvester-5m(VPS, instances/harvester-5m): enabled, legacyDIRECTIONAL_*,entry_window_seconds=180, tag 102892; живой контейнер стартовалstrategy=Directional. Проверено прямым чтением VPS. Никакого включенияbitcoin-5mне требуется. - Триггер оценки (бывш. Q3) — РЕШЕНО: event-path primary. Живой periodic-path (плумбинг
orderbook_storeвStrategyDeps) НЕ делаем в этой задаче: для 5-минуток вход едет на event-path (WS Book → on_orderbook_update → live-admission), periodic остаётся backstop для weather/daily. Меньше LOC, нет регресса. Если позже понадобится живой periodic — отдельная задача. - Discovery (бывш. Q4) — СНЯТО. Пре-листинг ~24 ч (см. Риск #1) → discovery floor не кусается. Вне scope.
- Гейтинг частей (бывш. Q2) — ДЕФОЛТ: один knob
presubscribe_enabledна обе части (A+B). Поэтапный rollout через два раздельных knob — преждевременная оптимизация; при необходимости разделить можно позже.live_price_admission_enabledостаётся опциональным на будущее, по умолчанию не вводится.
Единственный открытый вопрос — go/no-go
Реализовать рекомендованный дизайн (Части A+B+C, один knob presubscribe_enabled)? После согласования: реализация в изолированном worktree → unit-тесты → следующий чекпоинт ПЕРЕД release-build + redeploy на VPS.
Нужен ли ADR
Да. Hot-loop финансовый бот, новое поведение подписки WS, новый источник цены для admission, новые config-knobs. Этот документ — драфт ADR на согласование.