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 0a31f45→e444af0 (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(branchfeat/pre-live-gating, commits0a31f45→e444af0, 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-onlyClobReadClientto the vendoredpolymarket-datacrate (best-ask via/book, market-status via/markets/{cond}). Added a startup announcement totelegram.outbound.- The 4 gates (all FAIL-CLOSED): (1)
category_filter+passes_filter+resolve_category; (2)chase_gateonmax_entry_premium_bps; (3)liveness_gatemarket open/active/accepting/not-resolved/not-resolution-proposed + min-time-to-resolution; (4) the oldev_floor_gateREMOVED (degenerate).- Build/test: 312 tests pass, clippy
--all-targets+ fmt clean, dormant-safe (no panic withPOLYGON_WSS_URL/NATS_URLunset).- 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 noGammaClientand (until this work) no CLOB read surface. Ifcohort_algodoesn’t gate on category / chase / liveness, nothing does — PM only does mode / breaker / portfolio-caps + (for thederivedarm) 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_filter — CategoryFilter{include, exclude} + passes_filter + resolve_category
A per-event category restriction. Two pieces:
- The filter grammar —
CategoryFilter{include, exclude}parsed from a comma-separated bucket string:!x= exclude bucketx, a barex= include bucketx, an empty filter = pass-all. Buckets are the coarse setesport/sport/geo/crypto/other(thepmv2_event_category.categorybucket 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 resolver —
resolve_categoryis cache-first: read thepmv2_event_categorytable → on a miss, fall back to gammaevent_tags→category_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_slugis empty,resolve_categoryshort-circuits toNonewithout 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_bps — chase_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 fact — market_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_gatewas price-blind and degenerate — removed, not replacedThe ported [[autotrade-gates#4-ev_floor_gate|
ev_floor_gate]] was not a real EV calculation:size_usdcancels out ofedge = size_usd · (bps/10_000)vscost = size_usd · 0.02, so it reduces to a constantbps > 200comparison — 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 settingmax_entry_premium_bpsnet of round-trip cost (chase gate #2). One knob, price-aware, no degenerate math.
min_round_trip_edge_bpsstays loaded-but-unused in the config struct — it is NOT deleted, because thepmv2_autotrade_configschema 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-clobcrate 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 freecur_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_askviaGET /bookmarket_statusviaGET /markets/{cond}
Both are public CLOB endpoints — NO signing, NO auth, NO order-execution surface. This is emphatically not the deleted execution/signing client (that lives wholly in position_manager — pmv2-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).
/bookreturns 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:
- Pure trust gates (zero I/O): score / conviction thresholds.
- Category (cache-first, one possible gamma RTT):
resolve_category+passes_filter. - Concurrent bounded I/O (
tokio::join!):best_ask+market_status+ resolution-proposed, fetched together rather than serially. - 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.outboundmessage on watcher boot — version-stamped, restart-dedupedOn watcher boot,
cohort_algopublishes onetelegram.outboundmessage:component: first_mover_core started with version: <hash> <title>. The version is captured at build time viabuild.rs(BUILD_GIT_HASH/BUILD_GIT_TITLE) — the same compile-time SHA-capture pattern as pmv2-bot-startup-ping. TheNats-Msg-Idisstartup|first_mover_core|<hash>, so same-version restarts dedup (the 5-min streamduplicate_windowswallows a flapping restart on the same build; a NEW build gets a NEW hash ⇒ a fresh announcement). This rides theNotifier::Outboundplane 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_corepmv2_autotrade_sourcesrow — PENDING (waiting on PM’s migrator; the source row is what makes the per-source(enabled, mode)lookup resolve to something other than fail-closedMode::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_*— whymin_round_trip_edge_bpsis loaded-but-unused — also holds until PM’s migrator ships; see cohort-algo-shared-db-migrator-ownership.)
Related
- 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
derivedpricing arm, and thetelegram.outboundflip 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_gateremoved here, andchase_gate/liveness_gate/price_bounds_gatecarried forward in spirit - autotrade-category-resolver — the cache-first
resolve_category(readpmv2_event_category→ gammaevent_tags→category_bucket) pattern this gate reuses - autotrade-resolution-proposed-entry-guard — the Gamma-only
umaResolutionStatusesresolution-proposed fact theliveness_gatechecks, and the band-gate-on-live-ask repoint - cohort-algo-shared-db-migrator-ownership — the
pmv2_autotrade_*schema FREEZE (PM-owned config table) that keepsmin_round_trip_edge_bpsloaded-but-unused instead of deleting it - cohort-algo-migration-squash-cleanup — the migration-source side of that same ownership split (
a3329dc): cohort dropped thepmv2_autotrade_*DDL + squashed to one baseline; the FREEZE that keepsmin_round_trip_edge_bpsundeletable is the same one this squash operates under - pmv2-polymarket-clob-crate-migration — the EXECUTION/signing CLOB client that moved into
position_manager; the newClobReadClientis explicitly NOT this (read-only, public endpoints, no signing) - 2026-06-20-fastest-polymarket-trade-submission — the CLOB
/book+ WSmarketchannel mechanics behind the best-ask read; the proxied-clientconnect_timeoutrule the read client honors - position-manager-interface-design — the EXECUTOR (
position_manager) these gated signals feed; PM has noGammaClient, which is WHY these gates must be producer-side - pmv2-bot-startup-ping — the
build.rscompile-time SHA-capture pattern the startup announcement reuses - telegram-connector-outbound-design — the
telegram.outboundplane 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