Skip to content

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схемаtaganchorentry_windowвывод
harvester-5m (instances/harvester-5m)1legacy DIRECTIONAL_*102892EndDate1805-мин крипто — АКТИВНАЯ цель
harvester-weather (instances/harvester-live)1legacy DIRECTIONAL_*103040GstLocalMidnight"" (пусто)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.

Риски ​

  1. Discovery floor — практически неактуален (проверено live Gamma). Polymarket пре-листит ~1995 будущих 5-минутных слотов на ~24 ч вперёд, все уже active=true, acceptingOrders=true. То есть рынок, который торгуется СЕЙЧАС, попал в MarketCache часами раньше — 60-с Gamma discovery floor для этих инструментов не кусается. Узкое место — исключительно «увидеть цену вовремя», что и закрывают Части A+B. Ускорение discovery (NewMarket) для текущей задачи не требуется.
  2. 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, а не сырой лимит. Риск низкий, но назвать явно.
  3. Race при ротации подписки. Между подпиской и первым book-snapshot токен получает price_change до snapshot — уже обрабатывается (drop delta, scanner.rs:807-814). Свежеподписанный рынок до первого Book не имеет живой цены; admission не сработает до snapshot — это корректно (не вход по пустому).
  4. Согласованность цены. ctx.book_bid/book_ask берутся из top-of-book уже отсортированного стакана (scanner.rs:919-920 сканирует все уровни — не first()); риск phantom $0.01 bid уже закрыт (commit 3f224f0, 2026-05-14).
  5. Влияние на другие стратегии. Закрыто доказательством аддитивности выше при presubscribe_enabled=false.
  6. 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 triggerscanner.rs:2670сигнатура rebuild может потребовать cache-хэндл
Live admissiondirectional.rs:5330-5383override input.best_bid/best_ask из ctx.book_* перед evaluate_signal
Config structdirectional.rs:~276 (DirectionalConfig) + парсер+ 2-3 knobs
Schema/валидаторfrontend strategy-specific + config parsedefaults + типы

Сложность: средняя. Это ВАЙРИНГ существующей инфраструктуры (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, legacy DIRECTIONAL_*, 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 на согласование.