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 consumer97f96a3→ noise-floor4698fc5→ conviction36a4264→ consensus0f71f89(current). SigNoz service name:polymarket-fetch-pmv2-tracked-alerts. The per-alert log linetracing::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 detectorpmv2-tracked-detectorpublishing aTrackedEventJSON to subjecttracked.signalsfor every cohort buy/sell fill. Phase 2 (this note) turns that stream into Telegram alerts. It is NOT thecohort.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/Rejectcore. The current filter is the consensus gate below; theCOREbox is noweligible_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 subjecttracked.signals→pmv2-tracked-alerts(this consumer) → Telegram. Verified live via SigNoz with sub-second detection latency (chain fill → publishedTrackedEvent). 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/ merge36a4264Alert only on scored traders whose order notional is ≥ 2× that trader’s
avg_bet_size(reusedconviction_multiplefrompmv2::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 absoluteNOTIONAL_FLOOR_USD = 25+ 6h dedup cooldown — superseded for exactly this reason.) Ops gotcha learned here: a pause→redeploy must use--deliver newto 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 converge —
6c03528/ merge0f71f89Fire 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>>whereMarketKey = { condition_id, outcome_index }, with a 48h window.eligible_buygate:side == "BUY"AND wallet is in the cohort ANDtrader.score > MIN_TRADER_SCORE (0.0). Sells and non-sharps are dropped before the tracker.record_buyprunes 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-traderformat_consensus_alert(alert.rs).- Pruning also runs via
prune_allon 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 (hardcodedholders.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 pureparse → cohort lookup → format). The consensus rewrite (0f71f89) restructured this intoeligible_buy(theside==BUY+ sharp-score gate) + the statefulConsensusTracker::record_buy, since “fire on the 2nd sharp” needs cross-message state a stateless per-message function can’t hold.parse_trackedandformat_*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 (durablealertson streamTRACKED_SIGNALS). Refreshes the cached scored cohort at the loop top, parses each message, runseligible_buy → tracker.record_buy, and on a consensus hit sendsformat_consensus_alert.event.rs::parse_trackedis the trust boundary: fail-closed parse of the detector’s JSON into the ownedTrackedSignal.
Original product decisions (2026-06-18) — now superseded by the filter evolution
HISTORICAL — the launch design, before the per-fill flood forced the filter rework
These were the locked decisions at build time. Stage-1 go-live invalidated both, and the consensus filter (Stage 3, current) is the landing point. Kept for the WHY trail.
Decision 1 (superseded) — alert per fill, on adds not just entries. Original intent: one alert per cohort order (deduped by
order_hash), surfacing scored-trader name/rank/band + measured latency, dropping the batch alert’s “N holders” count and paper-copy line. → Reversed: per-fill was the cause of the ~1 alert/sec flood; the current lane fires once per 2-sharp convergence, not per order. Decision 2 (superseded) — alert on BOTH buys and sells. → Reversed: the consensus filter is BUY-only (eligible_buydrops sells). Real-time sell alerts are no longer emitted by the current filter (a sell-side consensus/exit signal could be a future addition).
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_alertpath 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-alertsexits 0 ifNATS_URLorNATS_TRACKED_ALERTS_NKEY_SEEDis 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)]onTrackedSignalis CORRECT and load-bearing
TrackedSignalis 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,pubdoes NOT exempt a never-read field from thedead_codelint, so removing the#[allow(dead_code)]re-failsclippy -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
Rejectstill fires a Telegram diagnostic, matching the autotradenats::runconsumer’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:
- NKey —
NATS_TRACKED_ALERTS_NKEY_SEED, a consume-only identity on subjecttracked.signals.- Stream — JetStream stream
TRACKED_SIGNALScapturing subjecttracked.signals, with a durable pull consumeralerts.- Service —
pmv2-tracked-alertsruns as a long-lived service (alongsidepmv2-consumer), SigNoz namepolymarket-fetch-pmv2-tracked-alerts.Redeploy gotcha: because the consumer is a durable pull consumer, a pause→redeploy should use
--deliver newto skip the JetStream backlog — otherwise it replays the backlog through the (now possibly stricter) filter on restart.
Related
- cohort-firstmover-nats-migration — the other NATS lane (autotrade ENTRY signal on
cohort.signals→ streamWEATHER_SIGNALS); this tracked-alerts lane is separate (subjecttracked.signals, streamTRACKED_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-wins — important 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