Phase 4 of the on-chain settlement/fills/cohort-exit plan (branch autotrade-onchain-book): a SELL branch in the tracked-alerts consumer that, on a real on-chain cohort SELL, runs a per-wallet exit snapshot-diff seconds after the fill instead of waiting up to ~30 min for the cron. The money-critical decision is how it dedups against the cron so it never double-alerts or double-sells — and the answer is log the EXITED row + delete the prior snapshot row (riding the existing cursors), not firing a direct alert/sell from the chain path.

Superseded for the out-of-process executor (2026-06-22)

This note describes the on_cohort_sell behavior when the executor was in-process (log the EXITED row + delete the snapshot, then let the cron’s pmv2_exit_alert / autotrade_exits cursors pick it up). After the producer/executor service split, the executor lives out-of-process over NATS, so on_cohort_sell now also publishes an action:Sell to cohort.signals + sends a Telegram alert (the DB bookkeeping below is retained). See cohort-signals-service-split-and-exit-signals — including the exit-discriminator signal_id gotcha that keeps the Sell from colliding with the entry Buy on dedup_key.

For Agents — the one-paragraph version

The chain SELL path does detect_exited scoped to {wallet}, and for confirmed exits it logs the EXITED row (repo::insert_position_event) and deletes the prior pmv2_positions rows (repo::delete_positions) — mirroring exactly what the cron’s run_pmv2_positions does. It does NOT send a direct alert and does NOT call run_exits/auto_sell itself. Deleting the prior snapshot row means the cron’s next detect_exited won’t re-see it → exactly one EXITED row exists → the existing pmv2_exit_alert cursor alerts once and run_exits sells once. The chain path adds no new alert source and no new sell path — it just makes the same row appear seconds early. Files changed: only alerts_consumer.rs + the main.rs call-site.

What was built

A SELL branch in crates/polymarket-fetch/src/pmv2/tracked/alerts_consumer.rs — the same consumer that holds the BUY-side consensus filter (tracked-alerts-consumer, which explicitly flagged its unused TrackedSignal fields as “forward-looking for Phase-4 downstream consumers” — this is that Phase 4).

On a cohort SELL detection (TrackedSignal.side == "SELL", trader in cohort with score > MIN_COHORT_SCORE = 0.0), it runs a fast per-wallet exit check that accelerates exits off the ~30-min cron.

New symbols:

SymbolRole
eligible_sell(sig, score)gate: side == "SELL" AND cohort member AND score > MIN_COHORT_SCORE (0.0)
ExitDebounceper-wallet 60s coalesce — collapses a burst of sells from one wallet into a single exit pass
on_cohort_sell(wallet)the per-wallet exit pass (snapshot diff → log + delete)

Wiring into run_tracked_alerts (signature gained cfg: &AppConfig):

  • a DataApiClient via cfg.build_data_client (already sets connect_timeout — see the proxied-client silent-hang rule)
  • a tokio::Semaphore (cap 4) bounding the per-wallet fan-out
  • the ExitDebounce instance

Blast radius: exactly 2 files

alerts_consumer.rs (the branch + new symbols) and main.rs (the call-site now passing cfg). Nothing else.

The crux — three consumers of an EXITED row, each with its OWN dedup

The exit pipeline already has three independent consumers of an EXITED row. Phase 4 had to slot in without adding a fourth dedup surface. Understanding the three existing ones is the whole decision:

ConsumerWhat it doesIts dedup mechanism
Cohort EXIT alert (operator-facing) — crate::pmv2::exits::run_checkreads recent_exits filtered detected_ts > the pmv2_exit_alert cursor; advances cursor to max alerted detected_tsthe pmv2_exit_alert timestamp cursor in pm_detection_cursors (pmv2-exit-alerts)
auto_sellrunner::run_exitsreads autotrade_exit_events past the autotrade_exits cursor; the actual sell is gated by autotrade_sellable_for_origin (WHERE status='open') AND mark_sellingthe autotrade_exits cursor + the mark_selling open→selling CAS (autotrade-repo-exit-helpers, autotrade-selling-state-foundations)
insert_position_eventa plain INSERT — NO ON CONFLICTnone at the table level (see below)

pmv2_position_events has NO unique constraint — you CANNOT dedup exits at the DB layer

The UNIQUE(proxy_wallet, condition_id, outcome_index) constraint on pmv2_position_events was DROPPED in migration 20260603000000_pmv2_overlap.sql (the self-wallet-overlap design — the table became an append-only lifecycle log so enter→exit→re-enter can coexist; see Migration 20260603000000_pmv2_overlap). insert_position_event is a plain INSERT with no ON CONFLICT. Duplicate EXITED rows ARE possible at the table level. Dedup therefore lives in the consumers’ cursors + the mark_selling CAS, never in a DB constraint.

mark_selling is the load-bearing concurrency guard

mark_selling = atomic UPDATE ... SET status='selling' WHERE id=$1 AND status='open', returning true only if 1 row transitioned. It serializes all three sell triggers (cohort-exit event / missed-exit queue / 1-click Sell): only one caller wins the open→selling transition, the rest see 0 rows and abort. This is the ultimate backstop even if a duplicate EXITED row slips through. (autotrade-selling-state-foundations)

The safety decision (why this is safe)

The chain SELL path does detect_exited scoped to {wallet} (snapshot diff: a live data-API fetch vs the prior pmv2_positions rows). For confirmed exits it:

  1. logs the EXITED row — repo::insert_position_event
  2. deletes the prior pmv2_positions rows — repo::delete_positions

This mirrors exactly what the cron’s run_pmv2_positions does (log_exit_events then delete_positions). It does NOT send a direct alert and does NOT call run_exits/auto_sell itself.

Why no double-alert / no double-sell:

  • Deleting the prior pmv2_positions row means the cron’s next detect_exited won’t see that position → the cron won’t log a second EXITED row → exactly one EXITED row exists → the existing pmv2_exit_alert cursor fires the operator alert exactly once, and run_exits sells exactly once.
  • Even if a duplicate row somehow appeared, the sell is still safe: the mark_selling open→selling CAS lets only one caller win.
  • The chain path adds no new alert source and no new sell path — it just makes the same EXITED row appear seconds after the on-chain SELL instead of up to 30 min later. The existing cursor-driven consumers then act on their normal fast cadence.

Flow — chain SELL slots a row in front of the cron; cursors do the rest

graph TD
    CHAIN["on-chain cohort SELL<br/>TrackedSignal side=SELL"] --> ELIG["eligible_sell<br/>cohort + score>0"]
    ELIG --> DEB["ExitDebounce<br/>per-wallet 60s coalesce"]
    DEB --> DIFF["detect_exited {wallet}<br/>live fetch vs pmv2_positions"]
    DIFF -->|confirmed exit| LOG["insert_position_event<br/><b>+ delete_positions</b>"]
    LOG --> ROW["exactly ONE EXITED row"]
    CRON["~30-min cron<br/>run_pmv2_positions"] -.->|prior row gone → no 2nd EXITED| ROW
    ROW --> CUR1["pmv2_exit_alert cursor<br/>→ operator alert ×1"]
    ROW --> CUR2["autotrade_exits cursor<br/>+ mark_selling CAS<br/>→ auto_sell ×1"]
    style LOG fill:#3d2020,stroke:#a55,color:#fff
    style ROW fill:#264653,stroke:#2a9d8f,color:#fff
    style CRON fill:#2d2d2d,stroke:#888,color:#fff

Rejected alternative — a direct alert from the chain path

Sending a direct EXIT alert from the chain path was considered and rejected. It would need its own cross-process dedup against the cron’s pmv2_exit_alert cursor, which is impossible to guarantee: the chain-path alert and the cron’s EXITED row carry different detected_ts, which defeats the timestamp cursor → it would double-alert. Logging the row IS the way to ride the existing cursor safely — it routes both the operator alert and the auto-sell through the single, already-correct dedup path.

Idempotence + cold-start

detect_exited is intrinsically idempotent

detect_exited (positions.rs:183) compares the live snapshot against prior pmv2_positions rows: a position already gone from the live snapshot is never re-exited, no matter how many SELL events fire. Cold-start (no prior pmv2_positions snapshot for the wallet) produces NO exit (tested) — there is no “prior” to diff against, so nothing is falsely reported as exited. (Same “a timeout/empty is not an exit” discipline as the watcher’s succeeded-set scoping in first-mover-overlap-watcher.)

Known / accepted limits

Intra-gap round-trips get no fast-path exit (spec, NOT a code comment)

If a cohort wallet enters AND exits within a single cron gap, the position is never snapshotted, so the fast path has no “prior” row to diff and produces no fast exit — this matches today’s cron coverage (the cron wouldn’t catch it either). The fast path only accelerates exits for already-snapshotted positions. (Per the repo’s no-comments rule, this limit lives here in the spec, not in the source.)

Gate

bash scripts/gate.shGATE_EXIT=0.

  • Binary crate 508 tests (507 pass, 1 ignored)
  • lib crate 99 pass
  • clippy -D warnings clean, fmt clean
  • 6 new unit tests: eligible_sell ×2, ExitDebounce coalesce / per-wallet / window-boundary ×3, cold-start detect_exited no-exit ×1

Relationship to other notes

This complements the autotrade engine spec (pmv2-autotrade-engine-design) and the project_autotrade-engine-spec auto-memory note — it supersedes neither. It is the SELL/fast-exit counterpart to the BUY-side consensus work in tracked-alerts-consumer (same consumer file, same on-chain detection substrate). It does not change the autotrade sell mechanism (mark_selling CAS, autotrade_exits cursor) — it only changes when the EXITED row that triggers that mechanism appears.