The four PRE-LIVE signal-gating gaps that were blocking cohort_algo from arming are now CLOSED — category_filter, chase / max_entry_premium_bps, and market-liveness gates are implemented, and the degenerate ev_floor_gate was DROPPED. All four sit in the first-mover ENTRY decision path and are FAIL-CLOSED: any fetch failure / timeout / missing datum ⇒ Skip (never emit an order blind). This is the work PRE-LIVE gating gaps — MUST close before arming flagged as “NOT fixed, REQUIRED before live.” Merged + pushed to github.com/wowjeeez/pmv2-cohort-algo main, commits 0a31f45e444af0 (branch feat/pre-live-gating, ff-merged). 312 tests pass, clippy --all-targets + fmt clean, dormant-safe.

For Agents — state at a glance

  • Repo: private github.com/wowjeeez/pmv2-cohort-algo, main @ e444af0 (branch feat/pre-live-gating, commits 0a31f45e444af0, ff-merged).
  • What changed: the first-mover ENTRY decision path (signal_gate_decision) now runs three new I/O gates + dropped one degenerate gate. Made async + staged cheapest-first. Re-added a read-only ClobReadClient to the vendored polymarket-data crate (best-ask via /book, market-status via /markets/{cond}). Added a startup announcement to telegram.outbound.
  • The 4 gates (all FAIL-CLOSED): (1) category_filter + passes_filter + resolve_category; (2) chase_gate on max_entry_premium_bps; (3) liveness_gate market open/active/accepting/not-resolved/not-resolution-proposed + min-time-to-resolution; (4) the old ev_floor_gate REMOVED (degenerate).
  • Build/test: 312 tests pass, clippy --all-targets + fmt clean, dormant-safe (no panic with POLYGON_WSS_URL / NATS_URL unset).
  • NOT live. Mode is still OFF. This closes deploy precondition (c) “4 pre-live gaps closed”; live-arming is still gated on the remaining preconditions + operator go-ahead.

Why this matters — PM cannot catch these

FAIL-CLOSED is load-bearing — the producer is the LAST line

Every one of these four gates is producer-side because the executor (position_manager) cannot re-derive them — PM has no GammaClient and (until this work) no CLOB read surface. If cohort_algo doesn’t gate on category / chase / liveness, nothing does — PM only does mode / breaker / portfolio-caps + (for the derived arm) the book-relative limit + $→shares math. So these gates fail CLOSED: a fetch failure, a timeout, or a missing field ⇒ Skip, never “emit the order anyway.” Emitting blind here is a capital risk (chasing a collapsed price, entering a resolution-proposed market, trading an out-of-category bet). The ownership split is the one from 5. Producer-owned signal-level gating signals gates rs (babylon #292): producer owns signal-level gates, PM owns mode/breaker/caps.

The 4 gates

1. category_filterCategoryFilter{include, exclude} + passes_filter + resolve_category

A per-event category restriction. Two pieces:

  • The filter grammarCategoryFilter{include, exclude} parsed from a comma-separated bucket string: !x = exclude bucket x, a bare x = include bucket x, an empty filter = pass-all. Buckets are the coarse set esport / sport / geo / crypto / other (the pmv2_event_category.category bucket namespace, NOT the 9 leaderboard categories — see pmv2-bet-link-analyzer-flow for the two coexisting taxonomies). passes_filter(&CategoryFilter, Option<&str>) applies it.
  • The resolverresolve_category is cache-first: read the pmv2_event_category table → on a miss, fall back to gamma event_tagscategory_bucket. This mirrors the on-demand resolver pattern from autotrade-category-resolver.

Empty event_slug short-circuits to None — skips a doomed gamma RTT

If the event_slug is empty, resolve_category short-circuits to None without hitting gamma. An empty slug can never resolve, so issuing the gamma round-trip would be a guaranteed-wasted network call on the latency-sensitive first-mover hot path (where the edge is perishable — valid_until_ts = now + 300s). Skip the doomed RTT.

2. chase / max_entry_premium_bpschase_gate

Skip the entry if best_ask > avg_price · (1 + premium / 10_000). This is an entry-decay check relative to the cohort trader’s fill (avg_price): if the book has run up past the configured premium since the copied trader entered, the first-mover edge is gone and chasing it is a loss. The comparison is against the live best_ask (our real entry price — see the band-gate repoint below), not the trader’s frozen reference. This is the same intent as the legacy chase_gate but now grounded in the real ask we’d pay.

3. market liveness — liveness_gate

Skip unless the market is genuinely tradeable:

  • active && accepting_orders && !closed && !resolved && !resolution_proposed
  • AND secs_to_resolution >= min

Status comes from the new ClobReadClient (/markets/{cond}). The resolution-proposed flag is a Gamma-only factmarket_resolution_proposed reads gamma umaResolutionStatuses (the JSON-encoded-string ["proposed"] state where the result is in but the market is still closed=false/acceptingOrders=true at a collapsed price). This is exactly the guard established in autotrade-resolution-proposed-entry-guard — the CLOB binding gives you nothing for resolution-proposed-ness, so it must be fetched from gamma separately.

4. EV-floor — DROPPED (was degenerate)

The old ev_floor_gate was price-blind and degenerate — removed, not replaced

The ported [[autotrade-gates#4-ev_floor_gate|ev_floor_gate]] was not a real EV calculation: size_usd cancels out of edge = size_usd · (bps/10_000) vs cost = size_usd · 0.02, so it reduces to a constant bps > 200 comparison — completely price-blind. It was a placeholder shaped like an EV floor. Rather than build a “real” EV floor, it was REMOVED: operators express the round-trip-cost guard directly by setting max_entry_premium_bps net of round-trip cost (chase gate #2). One knob, price-aware, no degenerate math.

min_round_trip_edge_bps stays loaded-but-unused in the config struct — it is NOT deleted, because the pmv2_autotrade_config schema is FROZEN per the PM-migrator decision (babylon #333 / the 331-era freeze — see cohort-algo-shared-db-migrator-ownership). You cannot drop a column from a frozen, PM-owned table; you just stop reading it.

Key architecture decision — the read-only ClobReadClient re-add

This deliberately RE-INTRODUCES a CLOB dependency the carve had removed

The carve (cohort-signals-service-split-and-exit-signals) dropped the polymarket-clob crate entirely — a signal producer was supposed to have zero CLOB dependency (It does NOT place trades). This work re-adds a CLOB dependency, but a strictly read-only one, and it is an operator-chosen tradeoff: best-ask accuracy (the real price we’d pay) over the free cur_price / gamma proxy. The chase gate is only as good as the price it compares against, so a real top-of-book read was judged worth the re-coupling.

ClobReadClient lives in the vendored polymarket-data crate and exposes exactly two reads:

  • best_ask via GET /book
  • market_status via GET /markets/{cond}

Both are public CLOB endpointsNO signing, NO auth, NO order-execution surface. This is emphatically not the deleted execution/signing client (that lives wholly in position_managerpmv2-polymarket-clob-crate-migration). It reuses the crate’s existing HttpExecutor / build_http_client (which sets connect_timeout — the proxied-hang trap; both timeout AND connect_timeout or you risk the 75-min silent hang) and the proxy pool.

Response structs grounded in REAL captured fixtures — three wire facts

The CLOB was not geoblocked, so the response shapes were captured from the live wire, not guessed:

  • The live wire is snake_case (not camelCase).
  • /book returns asks in DESCENDING price order — so .min() over the asks is load-bearing to get the actual best (lowest) ask. Taking the first element would give you the worst ask.
  • The structs match real captured fixtures, so the deserialize won’t silently drift.

The signal_gate_decision flow — async, staged cheapest-first

signal_gate_decision was made async and staged so the cheapest checks run first and the expensive network calls are skipped entirely when an earlier gate already rejects:

  1. Pure trust gates (zero I/O): score / conviction thresholds.
  2. Category (cache-first, one possible gamma RTT): resolve_category + passes_filter.
  3. Concurrent bounded I/O (tokio::join!): best_ask + market_status + resolution-proposed, fetched together rather than serially.
  4. Pure apply_io_gates (zero I/O on the fetched data): liveness → chase → band.

entry_price_band_gate was repointed to gate the best_ask (our real entry price) instead of the trader’s fill — same fix as the legacy band-gate-on-live-ask in autotrade-resolution-proposed-entry-guard, so a market that collapsed after the copied trader entered is now caught against the price we’d actually pay.

Deferred Minors (tracked, non-blocking)

The final whole-branch review flagged three minors that were accepted as non-blocking:

  • No test asserts trust-gates-run-before-fetches (the staging is enforced by a grep-guard only, not a behavioral test).
  • resolve_category / the I-O glue are untested (no mock harness for the network seam yet).
  • An exclude-only filter plus an unresolved category proceeds — this is the intended exclude semantic (an exclude filter only blocks named buckets; an unknown/unresolved category is not on the exclude list, so it passes).

Startup announcement (operator directive #354)

One telegram.outbound message on watcher boot — version-stamped, restart-deduped

On watcher boot, cohort_algo publishes one telegram.outbound message: component: first_mover_core started with version: <hash> <title>. The version is captured at build time via build.rs (BUILD_GIT_HASH / BUILD_GIT_TITLE) — the same compile-time SHA-capture pattern as pmv2-bot-startup-ping. The Nats-Msg-Id is startup|first_mover_core|<hash>, so same-version restarts dedup (the 5-min stream duplicate_window swallows a flapping restart on the same build; a NEW build gets a NEW hash ⇒ a fresh announcement). This rides the Notifier::Outbound plane the v2 cutover flipped on (7. Telegram routing flipped to the NATS outbound plane) → relayed by telegram_connector.

How it was built

Subagent-driven: per-task spec + quality reviews, plus a final whole-branch review. 312 tests pass, clippy --all-targets + fmt clean, dormant-safe.

Status & what’s next

Closes deploy precondition (c) — but cohort is still mode OFF

This work closes precondition (c) “4 pre-live gaps closed.” The other deploy preconditions:

  • (a/b) PM live on PMV2_ORDERS — DONE.
  • PM migrator inserts the first_mover_core pmv2_autotrade_sources row — PENDING (waiting on PM’s migrator; the source row is what makes the per-source (enabled, mode) lookup resolve to something other than fail-closed Mode::Off).

cohort_algo remains mode OFF. Live-arming is gated on PM’s migrator landing that source row + operator go-ahead. (The schema FREEZE on pmv2_autotrade_* — why min_round_trip_edge_bps is loaded-but-unused — also holds until PM’s migrator ships; see cohort-algo-shared-db-migrator-ownership.)

  • cohort-algo-v2-wire-contract-cutover — the cutover that FLAGGED these 4 PRE-LIVE gating gaps as “NOT fixed, REQUIRED before live”; this note CLOSES all four. Also the source of the producer-owns-signal-gates split (babylon #292), the derived pricing arm, and the telegram.outbound flip the startup announcement rides
  • cohort-algo-component — the standalone producer these gates live in; its “zero CLOB dependency” invariant is the one this work DELIBERATELY relaxes (read-only ClobReadClient)
  • autotrade-gates — the LEGACY executor-side gate chain these are the producer-side successors of; the degenerate ev_floor_gate removed here, and chase_gate / liveness_gate / price_bounds_gate carried forward in spirit
  • autotrade-category-resolver — the cache-first resolve_category (read pmv2_event_category → gamma event_tagscategory_bucket) pattern this gate reuses
  • autotrade-resolution-proposed-entry-guard — the Gamma-only umaResolutionStatuses resolution-proposed fact the liveness_gate checks, and the band-gate-on-live-ask repoint
  • cohort-algo-shared-db-migrator-ownership — the pmv2_autotrade_* schema FREEZE (PM-owned config table) that keeps min_round_trip_edge_bps loaded-but-unused instead of deleting it
  • cohort-algo-migration-squash-cleanup — the migration-source side of that same ownership split (a3329dc): cohort dropped the pmv2_autotrade_* DDL + squashed to one baseline; the FREEZE that keeps min_round_trip_edge_bps undeletable is the same one this squash operates under
  • pmv2-polymarket-clob-crate-migration — the EXECUTION/signing CLOB client that moved into position_manager; the new ClobReadClient is explicitly NOT this (read-only, public endpoints, no signing)
  • 2026-06-20-fastest-polymarket-trade-submission — the CLOB /book + WS market channel mechanics behind the best-ask read; the proxied-client connect_timeout rule the read client honors
  • position-manager-interface-design — the EXECUTOR (position_manager) these gated signals feed; PM has no GammaClient, which is WHY these gates must be producer-side
  • pmv2-bot-startup-ping — the build.rs compile-time SHA-capture pattern the startup announcement reuses
  • telegram-connector-outbound-design — the telegram.outbound plane the startup announcement publishes to
  • cohort-firstmover-nats-migration — the first-mover ENTRY lane whose decision path (signal_gate_decision) these gates now stage cheapest-first