cohort_algo is the pmv2 fleet’s cohort-intelligence SIGNAL PRODUCER — now its own standalone repo/component. It scores smart-money traders, fires cohort-consensus alerts, and emits first-mover ENTRY + cohort EXIT trade signals onto NATS for a downstream executor. It does NOT place trades — building/signing/POSTing orders is wholly the position_manager’s job (position-manager-interface-design). This note captures the component as it stands now (its own cargo workspace at 2e150b1) plus the pmv2 architecture context that makes it make sense.

For Agents — state at a glance

  • Home: /Users/levander/coding/pmv2/cohort_algo — private repo github.com/wowjeeez/pmv2-cohort-algo, main @ 2e150b1 (“golden wire pin + gate + README + de-stale repo self-references”).
  • Own cargo workspace (NOT in the old monorepo): resolver = "3", edition = "2024", rust-version 1.85, unsafe_code = "forbid". Deps: sqlx / tokio / anyhow / tracing / async-nats 0.49 (feature nkeys). Members: crates/cohort_algo + crates/polymarket-data.
  • Vendors polymarket-data as an internal workspace crate (gamma client / data-api / on-chain decode / Telegram client).
  • 278 tests pass, clippy + fmt green. Dormant-safe: no panic when POLYGON_WSS_URL / NATS_URL are unset (the on-chain watcher and the NATS publisher each idle silently).
  • UPDATE 2026-06-23 (0986c4d): the v2 wire-contract cutover is DONE. cohort now emits schema_version 2 on pmv2.order.first_mover_core.{entry,exit} (the old cohort.signals schema_v1 path was deleted). The blocker resolved — pmv2-contracts now exists and is vendored. See cohort-algo-v2-wire-contract-cutover for the full cutover (subjects, Pricing::Derived, producer-owned sizing+gating, the 4 PRE-LIVE gating gaps). The schema_v1 description elsewhere in this note is now historical.
  • Relation to cohort-signals-service-split-and-exit-signals: that note documents the in-monorepo carve (the signal-producer slice cut out of polymarket_fetch). THIS note documents the standalone component that carve was then ported into — a separate repo with its own workspace. Same behavior, new home.

What it is — the five responsibilities

cohort_algo owns exactly one concern: cohort intelligence. Within that it does five things:

#ResponsibilityMechanismOutput
1Cohort scoring cronranks smart-money traderswrites pmv2_traders / leaderboard, gated by pmv2_runs
2Cohort consensus alertsPmv2Consensus cron + on-chain ConsensusTrackeralert when ≥N Core-tier traders enter the same (condition_id, outcome)
3First-mover ENTRY signalsCore-tier trader’s first BUY on a marketpublish action:Buy → NATS cohort.signals
4Cohort EXIT signalsa tracked trader sells on-chainpublish action:Sell → NATS cohort.signals
5On-chain watcher + gap-backfillPolygon WSS OrderFilled feed + a 30s eth_getLogs sweepfeeds detection for 4

It does NOT place trades

A signal producer never signs or POSTs an order. cohort_algo has zero CLOB dependency — the polymarket-clob crate is not a member of this workspace and is not pulled transitively. The money-path (reserve → sign → submit → fills → reconcile → exits → settle) lives entirely in position_manager. cohort_algo’s job ends at “publish a well-formed signal onto NATS.”

cohort_algo (producer) → position_manager (executor) over NATS

graph TD
    subgraph PROD["cohort_algo — PRODUCER (this component)"]
        SCORE["1· scoring cron<br/>Pmv2Collect → pmv2_traders"]
        CONS["2· consensus alerts<br/>Pmv2Consensus cron + on-chain ConsensusTracker"]
        FM["3· first-mover ENTRY<br/>signals::firstmover"]
        EX["4· cohort EXIT<br/>on_cohort_sell → signals::sources::exit_signal"]
        WATCH["5· on-chain watcher<br/>WSS feed + 30s eth_getLogs backfill"]
    end
    WATCH --> CONS
    WATCH --> FM
    WATCH --> EX
    FM -->|"action:Buy"| SUBJ["NATS cohort.signals<br/><i>durable pmv2 (schema_v1 today)</i>"]
    EX -->|"action:Sell"| SUBJ
    SUBJ --> EXEC["position_manager (@deploy / @positionmanager) — EXECUTOR<br/>consumes NATS directly → gate(off/dry/live, caps, breaker)<br/>→ sign + POST + record + reconcile + exits + settle"]
    style PROD fill:#264653,stroke:#2a9d8f,color:#fff
    style SUBJ fill:#2d2d2d,stroke:#888,color:#fff
    style EXEC fill:#3d2020,stroke:#a55,color:#fff

Layout

The component keeps the internal crate::pmv2:: module tree from the monorepo (this is deliberate — see How it was built):

crates/cohort_algo/src/
├── main.rs              # subcommand dispatch (migrate / scheduled-run / pmv2-collect / pmv2-positions / pmv2-watch / pmv2-consensus / schedule-*)
├── cli.rs              # clap Command enum
├── config.rs          # AppConfig (env: SUPABASE_DB_URL, POLYGON_WSS_URL, NATS_URL, gamma/data-api bases)
├── notifier.rs        # Notifier: Direct (Telegram client) | Outbound (NATS telegram.outbound) | Noop
├── telegram_outbound.rs # build_outbound / publish_outbound (the telegram.outbound seam)
├── schedule.rs / scheduled_run.rs / schedule (launchd agent)
├── repo.rs / db.rs / observability.rs
└── pmv2/
    ├── mod.rs / model.rs / positions.rs / repo.rs   # cohort model + snapshot + DB
    ├── scoring.rs / category.rs / category_consensus.rs
    ├── signals/        # ← was autotrade/ in the monorepo (renamed in the purge)
    │   ├── nats.rs     # COHORT_SUBJECT="cohort.signals", publish_cohort (JetStream + PubAck)
    │   ├── wire.rs     # serialize_cohort, SCHEMA_VERSION_MAX=1 (schema_v1)
    │   ├── firstmover.rs   # build_and_publish_first_mover_entry
    │   ├── sources.rs  # SOURCE_ID="first_mover_core", enter_signal / exit_signal
    │   ├── signal.rs   # TradeSignal / cohort_signal_id / dedup_key
    │   └── config.rs   # reads pmv2_autotrade_config (caps/mode — read-only here)
    ├── tracked/        # alerts_consumer.rs (ConsensusTracker for the 2nd-sharp alert lane)
    └── watch/          # on-chain WSS watcher
        ├── mod.rs / cohort.rs / process.rs
        └── backfill.rs # the 30s eth_getLogs gap-backfill sweep

The signals/ module was autotrade/ in the monorepo

Part of the carve was an “autotrade purge”: the module autotrade/ was renamed signals/ because executing is the migrated-away position_manager’s concern, not the producer’s. What was kept is only the shared pmv2_autotrade_* DB table names (the producer still reserves/reads against the same physical tables the executor owns — see Shared DB surface). The directory name no longer claims a concern this component doesn’t have.

How it was built

Behavior-preserving port from the retiring polymarket_fetch carve

cohort_algo was ported behavior-preserving from the carved signal-producer slice of polymarket_fetch (carve at commit 483350c). Two deliberate continuity choices made the import nearly edit-free:

  1. Preserved the internal crate::pmv2:: module paths — so every use crate::pmv2::... resolves unchanged.
  2. Kept the polymarket-data crate name — so use polymarket_data::... is unchanged.

Net: zero logic edits during the port. This is the same “preserve byte-for-byte, prove it” discipline the NATS migration and the execution migration used.

Pre-port review pass — four real bugs fixed in the carve

Before the port, the carved slice went through a multi-lens review that fixed four real bugs (all in the carve, carried into cohort_algo):

(a) Empty-Data-API-snapshot mass-false-exit — the worst one

A transient 200 [] from the data-API (an empty snapshot, not a real “position closed”) would be read as the wallet exited its ENTIRE book → fabricated Sell signals for every position the wallet held. A momentary empty response could mass-exit a whole tracked book. Fixed: guard against an empty snapshot — an empty Data-API response no longer fabricates exits.

(b) Dropped-publish-still-deletes-the-row — lost-signal bug

The exit handler deleted the pmv2_positions row regardless of whether the publish actually succeeded. A failed publish + a deleted row = the exit is gone from the wire AND can never be re-detected (the snapshot no longer shows the position) = a permanent silent miss. Fixed: the delete is now gated on the publish outcome — publish-then-delete, delete only if publish succeeded. (Same lesson as the ENTRY publisher’s publish-before-delete ordering in cohort-signals-service-split-and-exit-signals.)

(c) Entry double-publish removed — watcher is the sole producer

Both the cron and the watcher were publishing the first-mover entry → the same entry could go out twice. Fixed: the watcher is now the sole entry producer; the cron path’s duplicate publish was removed. (The DB ON CONFLICT(dedup_key) and the JetStream Nats-Msg-Id dedup would have absorbed the double downstream, but emitting once is correct.)

(d) The "autotrade purge" rename — autotrade/ → signals/

The module was renamed (see above). Kept only the shared pmv2_autotrade_* table names; the directory no longer implies execution.

pmv2 architecture context — the split (why a standalone component)

cohort_algo only makes sense inside the larger pmv2 re-architecture. polymarket_fetch is RETIRING. Its concerns are being broken out into independent component repos under /coding/pmv2, wired together on a NATS spine instead of in-process calls. (Final arch = babylon #286, Andras’s Miro board.)

The final topology:

ComponentRepoRole
cohort_algopmv2-cohort-algo (this)PRODUCER — cohort scoring/consensus/first-mover/exit signals
weather producers (US + AS) + crypto(separate)PRODUCERS — own strategy + sizing, publish READY entry-orders + exit-signals
position_managerposition_managerEXECUTOR — consumes NATS, gates, signs, POSTs, records
telegram_connector(separate repo, handed to another agent)bidirectional Telegram interface

position_manager CONSUMES NATS directly — there is NO runner

A key arch decision: the executor consumes the NATS subjects directly. There is no separate “runner” process between the spine and the executor. position_manager owns: gate (off / dry / live + caps + breaker) → sign + POST + record; plus telegram.outbound publishing, an ops.commands / ops.results consumer (a confirmable kill-switch), and the pmv2_autotrade_* migrator (the single owner of those tables’ DDL).

Producers own strategy + sizing. A producer decides what to trade and how big; the executor decides only whether it’s allowed (mode/caps/breaker) and then does it. The boundary is “READY entry-order / exit-signal in, executed trade out.” (PM’s ownership of the pmv2_autotrade_* migrator was formalized in babylon #331 — see cohort-algo-shared-db-migrator-ownership.)

The v2 trade wire-contract (babylon #290 / #292)

DONE 2026-06-23 — cohort migrated to the v2 contract

cohort_algo now emits this contract (schema_version 2) on pmv2.order.first_mover_core.{entry,exit} as of 0986c4d. The section below describes the contract shape; the cutover itself is documented in cohort-algo-v2-wire-contract-cutover.

For Agents — the v2 envelope shape

Subjects: pmv2.order.<source>.<entry|exit> (cohort’s source = first_mover_core). The shared pmv2-contracts crate is the single source of truth for these types — vendor it, don’t hand-roll the structs. (pmv2-contracts now exists — github.com/wowjeeez/pmv2-contracts — and is vendored at crates/pmv2-contracts.)

ONE entry envelope, with a pricing discriminator:

DiscriminatorProducersFieldsWho derives the limit
limitweather, cryptolimit_price + size_sharesPM uses the producer’s limit directly
derivedcohortreference_price + conviction + size_usdPM derives the limit from the live book, does $→shares, applies lot-size

Lean exit-signal (cohort, crypto): condition_id + outcome_index + origin_ref. PM resolves our position row from those and full-exits it.

Corrections that matter for cohort (get these wrong = silent misroute / capital trap)

  • source MUST be the EXACT source_id DB keyfirst_mover_core, not a human label. A wrong value silently routes to Mode::Off (the per-source mode lookup fails-closed to Off) → the lane looks armed but trades nothing. (cohort_algo already uses SOURCE_ID = "first_mover_core"pmv2/signals/sources.rs.)
  • The exit MUST carry origin_ref — it’s how PM maps the Sell back to OUR position row. An empty origin_ref ⇒ the exit can’t be resolved ⇒ the position can never be auto-closed = capital trap. (This is the same field the entry parse fail-CLOSES on in cohort-firstmover-nats-migration.)
  • Producers OWN ev-floor / chase / category gating. PM has no GammaClient — it cannot re-run category or EV gates. Whatever gating the strategy needs must happen in cohort_algo before publish. PM only does mode/caps/breaker + (for derived) the book-relative limit + sizing math.

Current state vs the v2 cutover

HISTORICAL (pre- 0986c4d) — schema_v1 on cohort.signals, Direct telegram default

This was the state at 2e150b1: cohort_algo emitted the OLD cohort.signals schema_v1 envelope (COHORT_SUBJECT = "cohort.signals", SCHEMA_VERSION_MAX = 1 in pmv2/signals/wire.rs), with the telegram.outbound seam prepared but Direct-send still the default. As of 0986c4d this is no longer true — see the DONE callout below.

DONE 2026-06-23 ( 0986c4d) — v2 cutover complete

The v2 cutover SHIPPED. cohort_algo now:

  1. Publishes to pmv2.order.first_mover_core.{entry,exit} (the old cohort.signals subject + signals/wire.rs + signals/signal.rs were DELETED).
  2. Emits schema_version 2 envelopes.
  3. Vendors the pmv2-contracts crate (crates/pmv2-contracts) as the type source of truth.
  4. ADDED producer-side sizingsize_usd via signals/sizing.rs (cohort is the Pricing::Derived arm), valid_until_ts = now+300s on entries, and producer-side signal-level gating via signals/gates.rs.
  5. Flipped Telegram to Notifier::Outbound (→ telegram.outbound) when NATS is configured; Direct is fallback only.

The blocker resolved — pmv2-contracts now exists. Full detail + the 4 PRE-LIVE gating gaps that must close before arming: cohort-algo-v2-wire-contract-cutover. UPDATE — those 4 gaps are now CLOSED (e444af0, FAIL-CLOSED category/chase/liveness + dropped degenerate ev-floor + a read-only ClobReadClient for best-ask/market-status): cohort-pre-live-signal-gating. cohort remains mode OFF; live-arming now waits only on PM’s migrator inserting the first_mover_core source row + operator go-ahead.

Shared DB surface

cohort_algo reads/writes a mix of tables it owns and tables it shares with the executor:

Tablecohort_algo role
pmv2_traders, leaderboardwrites (scoring cron output)
pmv2_runsgate for the scoring cron (run gating)
pmv2_positions, pmv2_position_eventssnapshot + exit bookkeeping
pmv2_autotrade_configreads caps / mode (it does NOT own these — position_manager does)
pmv2_autotrade_sourcesreads the per-source (enabled, mode) row keyed by source_id

cohort_algo does NOT migrate the pmv2_autotrade_* tables (formalized: babylon 333)

Per the split, the pmv2_autotrade_* migrator is the position_manager’s (4. Schema split). cohort_algo owns its OWN supabase/migrations (scoring / cohort tables) and reads the autotrade tables as a read-only precondition. babylon #331 (2026-06-23) made this concrete: PM owns the FULL pmv2_autotrade_* migrator; cohort DROPS all pmv2_autotrade_* DDL. CLEANUP NOW DONE (a3329dc, babylon #333): cohort squashed its 44 inherited migrations → ONE idempotent CREATE TABLE IF NOT EXISTS baseline for only the 12 tables it owns/reads (9 pmv2_* + 3 kept pm_*), dropped the pmv2_autotrade_* DDL + ~25 dead pm_*, and set set_ignore_missing(true) on the Migrator. The planned distinct sqlx history table was superseded — cohort is the sole sqlx owner (PM = Supabase), so squash + ignore_missing is cleaner. See cohort-algo-migration-squash-cleanup. The prod DB surgery (PM migrator first → pm_* DROPs → _sqlx_migrations reset) is @deploy’s (#380→#397). A schema FREEZE on pmv2_autotrade_* still holds.

Operational notes

Subcommands (from the README at 2e150b1)

migrate · scheduled-run · schedule-install/start/stop/status/uninstall (launchd) · pmv2-collect (scoring) · pmv2-positions (snapshot) · pmv2-category-sync · pmv2-watch (on-chain watcher — dormant unless POLYGON_WSS_URL set) · pmv2-consensus <category> (consensus report).

Dormant-safe by design

  • No POLYGON_WSS_URL ⇒ the on-chain watcher idles silently (no panic). The first-mover / exit / consensus-tracker lanes that depend on the WSS feed simply don’t fire.
  • No NATS_URL ⇒ the publisher is dormant; nothing is published. (The historical footgun here — a core-NATS publish that silently drops into nothing — was fixed by publishing through JetStream and awaiting the PubAck; see The landmine.)
  • Required env is just SUPABASE_DB_URL (the scoring cron / DB work always runs); the on-chain + NATS lanes are opt-in via their env.
  • cohort-signals-service-split-and-exit-signals — the in-monorepo carve this component was ported FROM (the signal-producer slice cut out of polymarket_fetch); covers the carve method, the deleted ~13,400 lines, and the cohort-exit action:Sell publish + the exit-discriminator dedup correctness
  • cohort-firstmover-nats-migration — the first-mover ENTRY → cohort.signals lane (action:Buy); defines the signal_id/dedup_key identity, the JetStream-PubAck publish, and the “dropped publish = permanent miss → make it loud” lessons that the pre-port review re-applied
  • pmv2-position-manager-nats-consumer — the consumer side of the spine (Pmv2Consumer, wire::parse_signal, Directive vs Context, the ON CONFLICT(dedup_key) idempotency the exit-discriminator protects)
  • position-manager-interface-design — the EXECUTOR (position_manager) this producer feeds; the schema-ownership split (PM owns the pmv2_autotrade_* migrator)
  • position-manager-execution-migration-implemented — the executor-side carve (the sibling subtraction: execution OUT of polymarket_fetch INTO position_manager)
  • watcher-onchain-gap-backfill — the 30s eth_getLogs gap-backfill sweep (watch/backfill.rs) preserved byte-for-byte through the carve + port; feeds the cohort detection this component depends on
  • first-mover-overlap-watcher — the overlap/self-trade watcher that was an EXECUTION-trigger lane, stripped in the carve (NOT part of cohort_algo)
  • tracked-alerts-consumer — the 2nd-sharp consensus-alert lane (tracked/alerts_consumer.rs, the ConsensusTracker) carried into this component
  • pmv2-polymarket-clob-crate-migrationpolymarket-clob moved into position_manager; confirms cohort_algo has ZERO CLOB dependency
  • cohort-algo-v2-wire-contract-cutoverthe v2 wire-contract cutover (0986c4d, 2026-06-23) that this note’s “v2 cutover — planned, BLOCKED” section was waiting on; schema_v2 on pmv2.order.first_mover_core.{entry,exit}, producer-owned sizing+gating, the 4 PRE-LIVE gating gaps
  • cohort-pre-live-signal-gatingthe follow-on (e444af0, 2026-06-23) that CLOSES those 4 PRE-LIVE gating gaps: FAIL-CLOSED category_filter / chase / market-liveness gates + dropped the degenerate ev-floor; re-adds a read-only ClobReadClient to polymarket-data (best-ask + market-status — DELIBERATELY relaxing this component’s zero-CLOB-dependency invariant) + a build-stamped startup announcement on telegram.outbound
  • cohort-algo-shared-db-migrator-ownershipbabylon 333 (2026-06-23): PM owns the FULL pmv2_autotrade_* migrator; cohort drops that DDL; the pm_* prune (now DONE)
  • cohort-algo-migration-squash-cleanupthe IMPLEMENTATION of that cleanup (a3329dc, 2026-06-23): 44 migrations squashed → ONE idempotent baseline (12 tables) + set_ignore_missing(true); the planned distinct-history-table approach was superseded because cohort is the sole sqlx owner (PM = Supabase)
  • sqlx-shared-migrations-table-trap — the one-migrator-per-(history-)table discipline behind cohort_algo NOT migrating the pmv2_autotrade_* tables (and the set_ignore_missing escape hatch the squash used)
  • pmv2-autotrade-engine-design — the locked engine design, now wholly on the executor side
  • polymarket-fetch — project overview (the retiring monorepo)