The Bets tab is the 4th tab on /polymarket — a read-only feature with an internal sub-nav of three sub-views: Bet View | Self Bets | Trade Analyzer. All three shipped 2026-06-04 (feature complete). Cross-agent feature requested by @code (owner of the polymarket-fetch Rust pipeline) over the file channel polymarket_fetch/AGENT_HANDOFF.md. The scoring math (Bet View) and the gauge math (Trade Analyzer) are faithful ports of @code’s Rust (model.rs scoring + consolidate_gauge/event_merge_key/is_date_token), each code-reviewed and confirmed. Built via the superpowers brainstorm→spec→plan→subagent-TDD→verification workflow. ~40 unit tests across the three views, tsc clean. No migration (read-only over @code’s tables). Merged to master locally; push gated on operator go.

Bets tab complete (2026-06-04)

All three sub-views — Bet View, Self Bets, Trade Analyzer — are shipped and merged locally on master. All three are folded into polymarket_fetch/CRM_DASHBOARD_PROMPT.md per @code. Push is the only remaining step (operator-gated).

For Agents

  • Route: the Bets tab (4th tab) on the /polymarket shell. Internal sub-nav: Bet View | Self Bets | Trade Analyzer. All three live now.
  • Domain core (pure, unit-tested): web/src/lib/pmv2-bets.ts — shared across all three sub-views. Bet View: conviction, bandOf, cohortEntry, aggregateCohortBets, BAND_TONE. Self Bets: latestSelfRunIds, parseSelfPosition, attachCohortBacking, cohortBetKey. Trade Analyzer: isDateToken, eventMergeKey, normalizeSlug, consolidateGauge. Plus formatters + num/numOrNull coercion.
  • Hooks: useCohortBets (Bet View), useSelfBets (Self Bets), useAnalyzerEvent (Trade Analyzer) under web/src/lib/hooks/pmv2-bets/.
  • UI: web/src/pages/polymarket/bets/BetsDashboard.tsx (shell + sub-nav), BetViewTable.tsx (+ filters), SelfBetsView.tsx, TradeAnalyzer.tsx, BandBadge.tsx. Tests across all three.
  • Data: same single CRM Supabase (mkofmdtdldxgmmolxxhc, “mgmt” — use useSupabase(), NO separate client). Read-only; @code’s pipeline owns the tables. Same as never precedent as trader-scout-dashboard.
  • Spec: docs/superpowers/specs/2026-06-04-polymarket-bets-tab-design.md (all 3 sub-views). Plans: docs/superpowers/plans/2026-06-04-polymarket-{bet-view,self-bets,trade-analyzer}.md.

For Agents — cross-cutting facts (apply to all three sub-views)

  • ~40 unit tests across the three views; each port reviewed — the scoring (Bet View) and the gauge (Trade Analyzer) were confirmed faithful ports of @code’s Rust.
  • All pmv2_* / pm_* tables are absent from database.types.ts → every .from()/.eq() uses as never with explicit row generics. Same precedent as trader-scout-dashboard.
  • PostgREST numeric → string, always → coerce via num/numOrNull (never trust a numeric column to arrive as a JS number).
  • Trader names come from pmv2_leaderboard_entries, NOT pmv2_traders (the scored/positions tables are wallet-keyed only) — buildWalletNameMap.

Sub-view 1 — Bet View

What it is

A “what is the smart-money cohort actually betting on?” view. Instead of ranking traders (Scout does that), it ranks bets: every position the scored cohort holds is grouped by market outcome (condition_id, outcome_index), each holder’s stake is weighted by both their trader score and their per-position conviction, and the group’s total bet_score ranks the table. Bands (VHIGH→VLOW) give a fast read on conviction quality. No trading, no writes.

Canonical scoring math (ported from @code’s Rust model.rs)

For Agents — replicate EXACTLY, this is a cross-agent contract

These formulas mirror @code’s model.rs. If @code changes the Rust, this TS must follow. The port was code-reviewed and confirmed faithful. All implemented in web/src/lib/pmv2-bets.ts.

  • conviction = clamp((initial_value − avg_lost_bet_size) / (avg_won_bet_size − avg_lost_bet_size), 0, 1)
    • 0 when either avg is null, or when avg_won_bet_size ≤ avg_lost_bet_size (degenerate/inverted range).
  • bet_score = Σ over holders (pmv2_traders.score × conviction).
  • Bands (bandOf): VHIGH ≥ 0.06, HIGH ≥ 0.035, NEUTRAL ≥ 0.02, LOW ≥ 0.008, else VLOW.
  • cohort_entry (cohortEntry) = Σ(avg_price · initial_value) / Σ(initial_value) — stake-weighted average entry price.
  • top holder = the holder with max score × conviction.
  • rank = COALESCE(rank_month, rank_week, rank_day).
  • dust filter = keep only positions where redeemable = false AND initial_value > 1.

Architecture (repo-standard Page → hook → Supabase, three layers)

1. Domain — web/src/lib/pmv2-bets.ts (pure, framework-free)

  • conviction(initial_value, avgWon, avgLost) — the clamp formula above.
  • bandOf(betScore)VHIGH|HIGH|NEUTRAL|LOW|VLOW.
  • cohortEntry(positions) — stake-weighted entry price.
  • aggregateCohortBets(...) — the core reducer: dust-filter → group by (condition_id, outcome_index) → compute bet_score, cohort_entry, top holder, rank, band.
  • BAND_TONE — band → StatusBadge tone map.
  • Formatters (price/score/percent).

2. Hook — web/src/lib/hooks/pmv2-bets/useCohortBets.ts

Resolves the latest completed pmv2_runs, then loads for that run:

  • pmv2_traders (cohort + per-trader score, avg_won_bet_size, avg_lost_bet_size, ranks),
  • pmv2_leaderboard_entries (names via buildWalletNameMap),
  • pmv2_positions (the live position snapshot — NOT run-scoped, see gotcha),
  • pmv2_event_category (category labels, joined on event_slug).

All .from() use as never (tables absent from database.types.ts). 30s refetchInterval.

3. UI — web/src/pages/polymarket/bets/

  • BetsDashboard.tsx — the Bets-tab shell (will host all 3 sub-views; currently renders Bet View).
  • BetViewTable.tsx — sortable/filterable table + filter controls.
  • BandBadge.tsx — band pill, colored via BAND_TONEStatusBadge.

Gotchas & reusable lessons

pmv2_positions is NOT run-scoped — it's a live snapshot keyed by proxy_wallet

Unlike pmv2_traders (PK includes run_id), pmv2_positions is a live snapshot keyed by proxy_wallet with no run_id. To assemble a cohort’s bets you must join positions to the latest pmv2_traders run by proxy_wallet — do not assume positions belong to a run. This is the single most important join in the hook.

pmv2_traders has NO name columns — names come from pmv2_leaderboard_entries

Same enrichment pattern as trader-scout-dashboard: build a wallet→name map from pmv2_leaderboard_entries (buildWalletNameMap) and merge client-side. The scored/positions tables are wallet-keyed only.

pmv2_event_category joins on event_slug, NOT event_id

Category labels come from pmv2_event_category keyed by event_slug. Joining on event_id silently yields no category. Use the slug.

StatusBadge API — tone + label props, tones are a fixed set

StatusBadge takes tone + label props (NOT children). Valid tones are exactly amber | emerald | rose | gray | sky. BAND_TONE maps each band to one of these — keep the map inside that set or the badge renders untoned.

New pmv2_* tables absent from database.types.ts

pmv2_positions and pmv2_event_category (like the other pmv2_* tables) are not in generated types → hook uses as never on .from()/.eq() with explicit row generics. Same precedent as trader-scout-dashboard. Cleaner follow-up: hand-add the pmv2_* tables to packages/shared/src/database.types.ts (see tech-debt).

Live-validated reference row (sanity check after changes)

Top bet at build time: will-the-us-invade-iran-before-2027 (outcome 1) — bet_score ≈ 0.0925VHIGH, 5 traders, ~$220K cohort stake, cohort entry 0.711 → 0.835. Use this to sanity-check the reducer after any math change.

If you touch this next

  1. Treat the scoring math as a cross-agent contractweb/src/lib/pmv2-bets.ts mirrors @code’s model.rs. If results drift, re-diff against the Rust before “fixing” the TS.
  2. Confirm the positions joinpmv2_positions is live + wallet-keyed; always join to the latest completed run’s pmv2_traders by proxy_wallet.
  3. Hand-add pmv2_* tables to database.types.ts to drop the as never casts across the Bet View + Self Bets + Trade Analyzer + Scout hooks.
  4. All three sub-views share BetsDashboard.tsx (shell + sub-nav) and the domain module web/src/lib/pmv2-bets.ts — extend the module/shell, don’t fork.

Sub-view 2 — Self Bets

What it is

The operator’s own book. It loads the operator’s tracked wallets (Balu / Bandi / others in pm_self_wallets) and shows each position, partitioned into Open and Resolved, annotated with cohort backing — i.e. “does the smart-money cohort also hold this exact bet, and at what conviction band?“. Bridges your own positions against the Bet View cohort signal.

Data source CHANGED (commit afd8740, 2026-06-15) — now live from Polymarket data-api, NOT the snapshot tables

As of the 2026-06-15 fix, Self Bets no longer reads pm_runs/pm_positions at all. It fetches live from Polymarket’s public data-api per wallet. The snapshot-based flow described below this callout is superseded — kept only as the history of why the change was made. See the bug-fix section at the bottom for the current data flow.

For Agents — CURRENT Self Bets data flow (post- afd8740)

  1. pm_self_wallets → the operator’s tracked wallets (the only snapshot/CRM table still read).
  2. For each wallet, GET https://data-api.polymarket.com/positions?user=<wallet>&sizeThreshold=0&limit=500 directly from the browser SPA (CORS-open, no proxy/edge fn). sizeThreshold=0 is required (default 1 hides small positions).
  3. Map each camelCase row via parsePolymarketPositionSelfPosition (now carries a redeemable field).
  4. Cohort backing as before: match (condition_id, outcome_index) against the Bet-View cohort (attachCohortBacking, reusing useCohortBets).
  5. Render grouped per wallet: an Open table + a collapsible <details> “Resolved (N)” section.

Key behaviors & differences from Bet View

Self Bets keeps dust — these are OUR OWN positions

Unlike Bet View (which dust-filters redeemable=false AND initial_value>1), Self Bets keeps all positions including dust — they’re the operator’s own book. sizeThreshold=0 on the data-api call is what guarantees small positions arrive.

Open vs Resolved partition (replaces the old "held-only" filter)

Each wallet’s positions are split by redeemable: Open (redeemable=false) in the main table, Resolved (redeemable=true, typically $0 settled) in a collapsible <details> “Resolved (N)” section. Previously the page hid resolved entirely — that was the bug.

Cohort backing reuses the Bet-View cohort, keyed on (condition_id, outcome_index)

attachCohortBacking matches a self position to a cohort bet via cohortBetKey = (condition_id, outcome_index) — the same group key Bet View aggregates on. Reuses useCohortBets, no second cohort computation.

Layers (current)

  • Domain (web/src/lib/pmv2-bets.ts): parsePolymarketPosition (camelCase data-api row → SelfPosition), attachCohortBacking, cohortBetKey. The old snapshot helpers (latestSelfRunIds, parseSelfPosition) remain exported but are now unused by this page.
  • Hook: useSelfBets — reads pm_self_wallets → per-wallet live data-api fetch → parsePolymarketPosition; merges cohort backing from useCohortBets. Per-wallet fetch failures fall back to []; the wallet-list query error still throws to the ErrorBanner.
  • UI: SelfBetsView (in web/src/pages/polymarket/bets/), grouped per wallet, each partitioned into Open + collapsible Resolved.

Bug fix — Self Bets hid resolved positions (2026-06-15)

Fixed locally on master (commit afd8740) — NOT yet pushed, NOT deployed

The fix is committed locally only. Until pushed + Cloudflare Pages redeploys, the live page still hides resolved bets.

Symptom: Self Bets “didn’t get all positions”. Live evidence: Bandi has 26 positions on Polymarket (9 open + 17 resolved), Balu 13 (3 open + 10 resolved); the page showed only the open ones. The 17/10 resolved are all currently $0 (settled).

Root cause — two layers:

  1. The external polymarket-fetch pmv2 self_snapshot scraper writes ZERO redeemable rows to pm_positions for self wallets — verified: 0 redeemable self-snapshot rows in the DB vs ~49.7K redeemable rows overall (from the cohort snapshots). So resolved self positions never existed in the data the page read.
  2. The CRM page also filtered them out: useSelfBets.ts had .filter(p => !p.redeemable). A CRM-only filter removal would have surfaced nothing — the DB had no redeemable self rows to show.

Fix (afd8740): Re-pointed useSelfBets to fetch live from Polymarket’s public data-api instead of the snapshot tables — GET https://data-api.polymarket.com/positions?user=<wallet>&sizeThreshold=0&limit=500 per wallet from pm_self_wallets.

Data-api gotchas worth remembering

  • sizeThreshold=0 is REQUIRED — the default is 1, which silently hides small positions.
  • The data-api is CORS-open (access-control-allow-origin: *) → the browser SPA calls it directly, no proxy or edge function needed.
  • Response is camelCase (proxyWallet, conditionId, outcomeIndex, redeemable, currentValue, …) → new parsePolymarketPosition mapper; SelfPosition gained a redeemable field.

Consequence — the self_snapshot agent now produces snapshots nobody reads

This page no longer touches pm_runs.selector='self_snapshot' / pm_positions / latestSelfRunIds. The external pmv2 self_snapshot agent (polymarket-fetch) is therefore producing snapshots no CRM surface consumes — a candidate to retire (operator’s call). The snapshot helpers stay exported in pmv2-bets.ts but are unused by Self Bets. Note this layer-1 root cause is the write-side mirror of self-sync-held-only-fix (which hid held-but-past-dated positions on the agent’s /wallets); here the agent simply never wrote redeemable self rows.

Verification: pnpm --filter web test pmv2-bets SelfBets54 passed; pnpm --filter web build green.


Sub-view 3 — Trade Analyzer

What it is

Paste a Polymarket URL or slug → the analyzer normalizes it, finds the matching event’s positions in @code’s pmv2_positions, and surfaces a per-outcome cohort gauge for that specific event. The gauge is @code’s exact consolidate_gauge Rust ported to TS, so the analyzer answers “for this market, which side is the smart-money cohort weighted toward, and how heavily?“.

For Agents — Trade Analyzer data flow

  1. InputnormalizeSlug: sanitize to [a-z0-9-] only.
  2. Match pmv2_positions rows where event_slug OR slug equals the normalized slug, held & non-dust.
  3. Join the latest pmv2_traders (for score + conviction inputs).
  4. consolidateGauge → per-outcome gauge sides (the ported Rust).

normalizeSlug sanitizing to [a-z0-9-] also closed a PostgREST .or() injection vector

The slug is interpolated into a PostgREST .or(event_slug.eq.<slug>,slug.eq.<slug>) filter. Sanitizing the input to only [a-z0-9-] (which normalizeSlug does for the gauge math anyway) doubles as the fix for a PostgREST .or() filter-injection vector — an unsanitized slug containing ,/./(/) could otherwise inject extra filter clauses. Keep the sanitization; it’s load-bearing for security, not just normalization.

The gauge math (faithful port of @code’s Rust consolidate_gauge)

For Agents — replicate EXACTLY, this is a cross-agent contract

isDateToken / eventMergeKey / normalizeSlug / consolidateGauge in web/src/lib/pmv2-bets.ts mirror @code’s is_date_token / event_merge_key / consolidate_gauge Rust. Code-reviewed, confirmed faithful. If @code changes the Rust, this TS must follow.

  • isDateToken(token) — true (drop the token) when it is:
    • a month name — full name OR 3-letter prefix (so may, jun drop),
    • a year in 2024–2031 inclusive,
    • an all-digit token,
    • a digit group matching \d+[-/]\d+ (e.g. 5/12, 3-4).
  • eventMergeKey(slug) — lowercase the slug, split on -, drop every date token (isDateToken), rejoin. This collapses date-suffixed market slugs onto a single canonical event key.
  • consolidateGauge(positions):
    • group sides by (event_merge_key, outcome_index),
    • per side cohort_score = Σ(score × conviction),
    • sort descending by cohort_score,
    • cap at MAX_GAUGE_SIDES = 8 — overflow goes into hidden_sides rendered as “+N more”,
    • n_markets = count of distinct condition_id folded into the event,
    • label = the modal event_slug (most common raw slug among the grouped positions).

Layers

  • Domain (web/src/lib/pmv2-bets.ts): isDateToken, eventMergeKey, normalizeSlug, consolidateGauge.
  • Hook: useAnalyzerEvent — normalize → match pmv2_positions by event_slug OR slug (held, non-dust) → join latest pmv2_tradersconsolidateGauge.
  • UI: TradeAnalyzer (in web/src/pages/polymarket/bets/) — paste box + per-outcome gauge sides (capped at 8, “+N more” overflow).
  • trader-scout-dashboard — the Scout tab; ranks traders, Bet View ranks bets; shares the pmv2_* tables, the buildWalletNameMap enrichment, and the as never/supabaseQuery precedent
  • cross-agent-handoff-channel — the file-channel pattern this feature was requested through (here the channel is polymarket_fetch/AGENT_HANDOFF.md, sender @code); all three sub-views are now folded into polymarket_fetch/CRM_DASHBOARD_PROMPT.md per @code
  • vuln-disclosure-tracker — the other cross-agent feature, requested by @bscvuln
  • paper-trading-dashboard — the canonical “single mgmt Supabase, no separate client” correction
  • polymarket-fetch — @code’s external Rust pipeline (model.rs) that owns the pmv2_* tables and the canonical scoring math; its self_snapshot agent is now a retirement candidate (see Self Bets bug fix)
  • self-sync-held-only-fix — the agent-side mirror of the Self Bets resolved-positions root cause (the self_snapshot scraper never wrote redeemable self rows)
  • integrations — Polymarket integration index entry
  • tech-debtdatabase.types.ts follow-up for the pmv2_* tables