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 repogithub.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(featurenkeys). Members:crates/cohort_algo+crates/polymarket-data.- Vendors
polymarket-dataas 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_URLare 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 onpmv2.order.first_mover_core.{entry,exit}(the oldcohort.signalsschema_v1 path was deleted). The blocker resolved —pmv2-contractsnow 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:
| # | Responsibility | Mechanism | Output |
|---|---|---|---|
| 1 | Cohort scoring cron | ranks smart-money traders | writes pmv2_traders / leaderboard, gated by pmv2_runs |
| 2 | Cohort consensus alerts | Pmv2Consensus cron + on-chain ConsensusTracker | alert when ≥N Core-tier traders enter the same (condition_id, outcome) |
| 3 | First-mover ENTRY signals | Core-tier trader’s first BUY on a market | publish action:Buy → NATS cohort.signals |
| 4 | Cohort EXIT signals | a tracked trader sells on-chain | publish action:Sell → NATS cohort.signals |
| 5 | On-chain watcher + gap-backfill | Polygon WSS OrderFilled feed + a 30s eth_getLogs sweep | feeds detection for 4 |
It does NOT place trades
A signal producer never signs or POSTs an order.
cohort_algohas zero CLOB dependency — thepolymarket-clobcrate is not a member of this workspace and is not pulled transitively. The money-path (reserve → sign → submit → fills → reconcile → exits → settle) lives entirely inposition_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 wasautotrade/in the monorepoPart of the carve was an “autotrade purge”: the module
autotrade/was renamedsignals/because executing is the migrated-awayposition_manager’s concern, not the producer’s. What was kept is only the sharedpmv2_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_fetchcarve
cohort_algowas ported behavior-preserving from the carved signal-producer slice ofpolymarket_fetch(carve at commit483350c). Two deliberate continuity choices made the import nearly edit-free:
- Preserved the internal
crate::pmv2::module paths — so everyuse crate::pmv2::...resolves unchanged.- Kept the
polymarket-datacrate name — souse 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 → fabricatedSellsignals 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_positionsrow 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 JetStreamNats-Msg-Iddedup 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:
| Component | Repo | Role |
|---|---|---|
| cohort_algo | pmv2-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_manager | position_manager | EXECUTOR — 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_managerowns: gate (off/dry/live+ caps + breaker) → sign + POST + record; plustelegram.outboundpublishing, anops.commands/ops.resultsconsumer (a confirmable kill-switch), and thepmv2_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 of0986c4d. 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’ssource=first_mover_core). The sharedpmv2-contractscrate is the single source of truth for these types — vendor it, don’t hand-roll the structs. (pmv2-contractsnow exists —github.com/wowjeeez/pmv2-contracts— and is vendored atcrates/pmv2-contracts.)
ONE entry envelope, with a pricing discriminator:
| Discriminator | Producers | Fields | Who derives the limit |
|---|---|---|---|
limit | weather, crypto | limit_price + size_shares | PM uses the producer’s limit directly |
derived | cohort | reference_price + conviction + size_usd | PM 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)
sourceMUST be the EXACTsource_idDB key —first_mover_core, not a human label. A wrong value silently routes toMode::Off(the per-source mode lookup fails-closed to Off) → the lane looks armed but trades nothing. (cohort_algoalready usesSOURCE_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 emptyorigin_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 incohort_algobefore publish. PM only does mode/caps/breaker + (forderived) the book-relative limit + sizing math.
Current state vs the v2 cutover
HISTORICAL (pre-
0986c4d) — schema_v1 oncohort.signals, Direct telegram defaultThis was the state at
2e150b1:cohort_algoemitted the OLDcohort.signalsschema_v1 envelope (COHORT_SUBJECT = "cohort.signals",SCHEMA_VERSION_MAX = 1inpmv2/signals/wire.rs), with thetelegram.outboundseam prepared butDirect-send still the default. As of0986c4dthis is no longer true — see the DONE callout below.
DONE 2026-06-23 (
0986c4d) — v2 cutover completeThe v2 cutover SHIPPED. cohort_algo now:
- Publishes to
pmv2.order.first_mover_core.{entry,exit}(the oldcohort.signalssubject +signals/wire.rs+signals/signal.rswere DELETED).- Emits schema_version 2 envelopes.
- Vendors the
pmv2-contractscrate (crates/pmv2-contracts) as the type source of truth.- ADDED producer-side sizing —
size_usdviasignals/sizing.rs(cohort is thePricing::Derivedarm),valid_until_ts = now+300son entries, and producer-side signal-level gating viasignals/gates.rs.- Flipped Telegram to
Notifier::Outbound(→telegram.outbound) when NATS is configured;Directis fallback only.The blocker resolved —
pmv2-contractsnow 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-onlyClobReadClientfor best-ask/market-status): cohort-pre-live-signal-gating. cohort remains mode OFF; live-arming now waits only on PM’s migrator inserting thefirst_mover_coresource row + operator go-ahead.
Shared DB surface
cohort_algo reads/writes a mix of tables it owns and tables it shares with the executor:
| Table | cohort_algo role |
|---|---|
pmv2_traders, leaderboard | writes (scoring cron output) |
pmv2_runs | gate for the scoring cron (run gating) |
pmv2_positions, pmv2_position_events | snapshot + exit bookkeeping |
pmv2_autotrade_config | reads caps / mode (it does NOT own these — position_manager does) |
pmv2_autotrade_sources | reads 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 theposition_manager’s (4. Schema split).cohort_algoowns its OWNsupabase/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 FULLpmv2_autotrade_*migrator; cohort DROPS allpmv2_autotrade_*DDL. CLEANUP NOW DONE (a3329dc, babylon #333): cohort squashed its 44 inherited migrations → ONE idempotentCREATE TABLE IF NOT EXISTSbaseline for only the 12 tables it owns/reads (9pmv2_*+ 3 keptpm_*), dropped thepmv2_autotrade_*DDL + ~25 deadpm_*, and setset_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_migrationsreset) is @deploy’s (#380→#397). A schema FREEZE onpmv2_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 unlessPOLYGON_WSS_URLset) ·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 thePubAck; 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.
Related
- 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-exitaction:Sellpublish + the exit-discriminator dedup correctness - cohort-firstmover-nats-migration — the first-mover ENTRY →
cohort.signalslane (action:Buy); defines thesignal_id/dedup_keyidentity, 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,DirectivevsContext, theON 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 thepmv2_autotrade_*migrator) - position-manager-execution-migration-implemented — the executor-side carve (the sibling subtraction: execution OUT of
polymarket_fetchINTOposition_manager) - watcher-onchain-gap-backfill — the 30s
eth_getLogsgap-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, theConsensusTracker) carried into this component - pmv2-polymarket-clob-crate-migration —
polymarket-clobmoved intoposition_manager; confirmscohort_algohas ZERO CLOB dependency - cohort-algo-v2-wire-contract-cutover — the v2 wire-contract cutover (
0986c4d, 2026-06-23) that this note’s “v2 cutover — planned, BLOCKED” section was waiting on; schema_v2 onpmv2.order.first_mover_core.{entry,exit}, producer-owned sizing+gating, the 4 PRE-LIVE gating gaps - cohort-pre-live-signal-gating — the 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-onlyClobReadClienttopolymarket-data(best-ask + market-status — DELIBERATELY relaxing this component’s zero-CLOB-dependency invariant) + a build-stamped startup announcement ontelegram.outbound - cohort-algo-shared-db-migrator-ownership — babylon 333 (2026-06-23): PM owns the FULL
pmv2_autotrade_*migrator; cohort drops that DDL; thepm_*prune (now DONE) - cohort-algo-migration-squash-cleanup — the 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 theset_ignore_missingescape 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)