Phase 2 of the pmv2 real-time tracked-detection lane: a long-running service pmv2-tracked-alerts that consumes the Phase-1 on-chain detector’s tracked.signals NATS stream and emits first-mover Telegram alerts — the whole point is to recover the first-mover edge lost to the ~30-min REST batch cadence (see autotrade-alert-latency-and-run-timeouts). Now LIVE in prod; the round-trip works end-to-end with sub-second detection latency. Once live, the alert filter went through three iterations as the operator tuned signal vs noise — landing on a consensus filter (alert only when a 2nd sharp cohort trader independently buys the same trade). See The filter evolution — per-fill → conviction → consensus.

For Agents — state at a glance (2026-06-19)

LIVE on main @ 0f71f89 (consensus filter). Cohort = 21. Correctly silent so far (0 sends) — consensus alerts are rare by design; the first fire is event-gated on a real 2-sharp convergence. Window/score/2-trader knobs await first real alerts to calibrate. Merge history: Phase-2 consumer 97f96a3 → noise-floor 4698fc5conviction 36a4264consensus 0f71f89 (current). SigNoz service name: polymarket-fetch-pmv2-tracked-alerts. The per-alert log line tracing::info!(target:"tracked_alerts", n=…, "consensus alert sent") makes the live alert rate finally visible (it had been a blind spot). This is the alerting consumer, separate from the autotrade lane. Phase 1 = the always-on on-chain detector pmv2-tracked-detector publishing a TrackedEvent JSON to subject tracked.signals for every cohort buy/sell fill. Phase 2 (this note) turns that stream into Telegram alerts. It is NOT the cohort.signals→autotrade lane from cohort-firstmover-nats-migration — different subject, different stream, different purpose (alert vs execute). Files: crates/polymarket-fetch/src/pmv2/tracked/{alerts_consumer.rs, alert.rs}.

Why this exists — recover the first-mover edge

The pre-existing entry alert is batch-derived: it fires from the 30-min scheduled-run watcher, so a cohort entry surfaces ~20–38 min late (autotrade-alert-latency-and-run-timeouts decomposes the lag). For a first-mover signal that lag erodes most of the edge. The Phase-1 detector already sees fills on-chain in real time; this consumer turns each one into an instant alert, replacing the batch entry alert’s role going forward.

Real-time tracked-detection lane (Phase 1 → Phase 2)

graph TD
    CHAIN["Polygon on-chain<br/>cohort OrderFilled"] --> DET["<b>pmv2-tracked-detector</b><br/><i>Phase 1, always-on</i><br/>publishes per-fill"]
    DET -->|"tracked.signals<br/>TrackedEvent JSON"| JS["NATS JetStream<br/>stream <b>TRACKED_SIGNALS</b><br/>durable pull consumer <b>alerts</b>"]
    JS --> CONS["<b>pmv2-tracked-alerts</b><br/><i>Phase 2, this note</i><br/>run_tracked_alerts loop"]
    CONS --> CORE["decide_alert(payload, cohort)<br/><b>pure core</b>"]
    CORE -->|"Send"| TG["Telegram<br/><i>instant buy/sell alert</i>"]
    CORE -->|"Skip"| ACK["ack, no alert"]
    CORE -->|"Reject"| RJ["ack + Telegram diag<br/><i>near-zero from our own publisher</i>"]
    style CONS fill:#264653,stroke:#2a9d8f,color:#fff
    style CORE fill:#3d2020,stroke:#a55,color:#fff
    style JS fill:#2d2d2d,stroke:#888,color:#fff

(Diagram shows the ORIGINAL launch flow — the decide_alert → Send/Skip/Reject core. The current filter is the consensus gate below; the CORE box is now eligible_buy → ConsensusTracker.)

Live — the round-trip works (verified in prod, 2026-06-19)

The lane is deployed and the full chain fires end-to-end:

Detector → tracked.signals → consumer → Telegram, sub-second

pmv2-tracked-detector (Phase 1, on-chain) → NATS subject tracked.signalspmv2-tracked-alerts (this consumer) → Telegram. Verified live via SigNoz with sub-second detection latency (chain fill → published TrackedEvent). The plumbing was never in doubt — the work after go-live was all about the filter, because per-fill granularity floods.

The filter evolution — per-fill → conviction → consensus

This is the load-bearing post-launch story. The Phase-2 design alerts on every cohort fill, which turned out to be far too noisy in prod. Three iterations, each fixing a real, observed problem:

Stage 1 — per-fill, no filter → ~1 alert/sec FLOOD

The new lane is per on-chain fill (every OrderFilled, buy and sell). The old batch lane structurally hid churn three ways: ~30-min cadence + position-snapshot-diff (only net change shows) + entry-only. None of that damping exists per-fill. A handful of high-frequency cohort wallets churning sub-second dominated the stream → ≈1 alert/sec. Unusable.

Stage 2 — conviction filter ( notional ≥ 2× the trader's own avg_bet_size) — e6a9041 / merge 36a4264

Alert only on scored traders whose order notional is ≥ 2× that trader’s avg_bet_size (reused conviction_multiple from pmv2::model; CONVICTION_MULT_FLOOR = 2.0). Selective and correct, just a touch noisy still. KEY DATA INSIGHT — why relative, not absolute: the “spamming” wallets were whales (avg bet 7.1K; the whole cohort avg-bets 80K). An absolute-dollar floor would have been WRONG — it never fires below the floor for small sharps and never filters anything for whales. Conviction relative to each trader’s own average is the right shape for a whale cohort. (The literal first filter attempt, 9817110, was an absolute NOTIONAL_FLOOR_USD = 25 + 6h dedup cooldown — superseded for exactly this reason.) Ops gotcha learned here: a pause→redeploy must use --deliver new to skip the JetStream backlog — otherwise the consumer replays the whole backlog through the new filter on restart.

Stage 3 — consensus filter (CURRENT) — alert only when the sharps converge6c03528 / merge 0f71f89

Fire an alert only when a 2nd positive-score (“sharp”) cohort trader independently BUYS into the same (condition_id, outcome_index) within a 48h window — i.e. “the sharps are converging here.” Single-trader bets no longer alert at all. This trades volume for precision: an alert now means independent agreement, not one wallet’s move.

Mechanism (alerts_consumer.rs):

  • In-memory ConsensusTracker = HashMap<MarketKey, HashMap<wallet, Instant>> where MarketKey = { condition_id, outcome_index }, with a 48h window.
  • eligible_buy gate: side == "BUY" AND wallet is in the cohort AND trader.score > MIN_TRADER_SCORE (0.0). Sells and non-sharps are dropped before the tracker.
  • record_buy prunes expired holders, inserts the wallet; if this buy makes the wallet the 2nd+ distinct sharp holder of that market key, it returns the holder set → fires a multi-trader format_consensus_alert (alert.rs).
  • Pruning also runs via prune_all on the 60s cohort-refresh tick, so stale holders age out even in a quiet market.
  • Tunable consts: CONSENSUS_WINDOW = 48h, MIN_TRADER_SCORE = 0.0, 2-trader threshold (hardcoded holders.len() >= 2).

Observability fix shipped alongside — the alert rate is finally visible

Per alert the consumer now logs tracing::info!(target: "tracked_alerts", n = set.len(), condition_id = …, "consensus alert sent"). Before this the live alert rate was a blind spot in SigNoz. Service name to query: polymarket-fetch-pmv2-tracked-alerts.

Known later-patch — Telegram 429 silently drops sends

A Telegram 429 (rate-limit) currently drops the send with no retry. Because consensus/conviction alerts are rare and high-value, a genuine alert landing inside a rate window can be lost outright. A send-retry / outbound queue is a worthwhile future patch.

Architecture — pure core wrapped by a thin NATS loop

The design splits a pure, unit-testable decision core from the I/O loop, mirroring the wire/consumer split in pmv2-position-manager-nats-consumer.

crates/polymarket-fetch/src/pmv2/tracked/
├── event.rs            owned TrackedSignal wire mirror + fail-closed parse_tracked
├── alert.rs            format_tracked_alert (the Telegram message)
└── alerts_consumer.rs  decide_alert (pure) + run_tracked_alerts (the NATS loop)
  • The decision core — originally decide_alert(payload, cohort) -> AlertDecision::{Send | Skip | Reject} (a pure parse → cohort lookup → format). The consensus rewrite (0f71f89) restructured this into eligible_buy (the side==BUY + sharp-score gate) + the stateful ConsensusTracker::record_buy, since “fire on the 2nd sharp” needs cross-message state a stateless per-message function can’t hold. parse_tracked and format_* stay pure and unit-tested; the tracker carries the small in-memory state. See The filter evolution — per-fill → conviction → consensus.
  • run_tracked_alerts — the long-running NATS JetStream pull consumer (durable alerts on stream TRACKED_SIGNALS). Refreshes the cached scored cohort at the loop top, parses each message, runs eligible_buy → tracker.record_buy, and on a consensus hit sends format_consensus_alert.
  • event.rs::parse_tracked is the trust boundary: fail-closed parse of the detector’s JSON into the owned TrackedSignal.

Original product decisions (2026-06-18) — now superseded by the filter evolution

Isolation — the batch alert stays as the safety net

Frozen lanes UNTOUCHED — and the batch alert is now a complement, not a redundancy

The frozen scoring/paper lane and the batch format_entry_alert / send_entry_alert path are not modified; the batch alert stays live. Post-consensus-filter this is no longer “retire the batch once we have parity” — the real-time lane is now intentionally rare (fires only on 2-sharp convergence), so it does not cover the batch alert’s per-entry surface. The two are complementary: batch = broad per-entry visibility (lagged); real-time consensus = sharp-convergence signal (instant). Keep both.

Dormant-safe deploy posture

Env-first, no DB touch — deploy-safe before provisioning

pmv2-tracked-alerts exits 0 if NATS_URL or NATS_TRACKED_ALERTS_NKEY_SEED is unset. The check is env-first and does not touch the DB, so the binary can ship before the NATS identity/stream are provisioned — same dormant-safe pattern as the pmv2-position-manager-nats-consumer subcommand.

Review triage — decisions worth remembering (with the reasoning)

These came out of the code-quality review; each is a deliberate keep, not an oversight — do not “fix” them.

#[allow(dead_code)] on TrackedSignal is CORRECT and load-bearing

TrackedSignal is the canonical owned wire mirror of the detector’s payload. Six of its fields — shares, fee_usd, block_number, tx_hash, order_hash, detected_at — are not surfaced by the alert today but are forward-looking for Phase-4 downstream consumers. In a binary crate, pub does NOT exempt a never-read field from the dead_code lint, so removing the #[allow(dead_code)] re-fails clippy -D warnings. (Same binary-crate dead-code subtlety as autotrade-onchain-balanceof / autotrade-repo-exit-helpers.) Keep the allow.

Cohort cache staleness is a non-issue — it refreshes BEFORE processing each message

The cohort cache refreshes lazily at the loop top, before processing each message (≥60s throttle). So the first message after a quiet period gets a freshly-loaded cohort. The “cohort goes hours-stale during a lull” concern does not hold — and cohort membership changes slowly anyway, so a 60s floor is ample.

Reject→Telegram-notify is kept (parity with the autotrade consumer)

A Reject still fires a Telegram diagnostic, matching the autotrade nats::run consumer’s behavior. Rejects are near-zero here because the publisher is our own Phase-1 detector (not an external producer like @weather), but the notify has real diagnostic value if the detector ever emits something the parser refuses. Keep it.

Deploy — DONE (live alongside the Phase-1 detector)

Provisioned and running (2026-06-19)

All three are in place; the lane is live on main @ 0f71f89:

  1. NKeyNATS_TRACKED_ALERTS_NKEY_SEED, a consume-only identity on subject tracked.signals.
  2. Stream — JetStream stream TRACKED_SIGNALS capturing subject tracked.signals, with a durable pull consumer alerts.
  3. Servicepmv2-tracked-alerts runs as a long-lived service (alongside pmv2-consumer), SigNoz name polymarket-fetch-pmv2-tracked-alerts.

Redeploy gotcha: because the consumer is a durable pull consumer, a pause→redeploy should use --deliver new to skip the JetStream backlog — otherwise it replays the backlog through the (now possibly stricter) filter on restart.

  • cohort-firstmover-nats-migration — the other NATS lane (autotrade ENTRY signal on cohort.signals → stream WEATHER_SIGNALS); this tracked-alerts lane is separate (subject tracked.signals, stream TRACKED_SIGNALS, alert-only — does NOT execute)
  • pmv2-position-manager-nats-consumer — the architectural sibling: same pure-core / NATS-loop split, same ParseOutcome-style fail-closed trust boundary, same dormant-safe subcommand pattern, same Reject→Telegram-notify behavior this consumer mirrors
  • autotrade-alert-latency-and-run-timeouts — the ops finding that motivates this: REST-batch detection puts buy alerts ~20–38 min behind source; this real-time lane is the recovery of the first-mover edge
  • autotrade-3c2-complete-handoff — flags the same strategic gap (“first-mover trigger is REST-polled ~30min … real-time on-chain WSS recommended before scaling”); the Phase-1 detector + this consumer are that real-time path for the ALERT side
  • first-mover-overlap-watcher — the batch-cadence overlap/exit alert; post-consensus-filter the real-time lane complements rather than supersedes it (the consensus alert is intentionally rarer than per-entry)
  • pmv2-autotrade-engine-design — the live-execution design; this is the alerting counterpart on the same on-chain detection substrate
  • xref-consensus-rejected-fresh-copy-winsimportant distinction: that note REJECTS “≥K traders agree” as a backtested copy-trade edge (first-mover fresh-entry copy won instead). This lane’s “2nd sharp converges” filter is a human-alert gate, not an auto-copy edge claim — a notify-the-operator signal layered on the real-time substrate, not a re-litigation of the rejected consensus-copy thesis
  • polymarket-fetch — project overview