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 intopolymarket_fetch/CRM_DASHBOARD_PROMPT.mdper @code. Push is the only remaining step (operator-gated).
For Agents
- Route: the
Betstab (4th tab) on the/polymarketshell. 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/numOrNullcoercion.- Hooks:
useCohortBets(Bet View),useSelfBets(Self Bets),useAnalyzerEvent(Trade Analyzer) underweb/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” — useuseSupabase(), NO separate client). Read-only; @code’s pipeline owns the tables. Sameas neverprecedent 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 fromdatabase.types.ts→ every.from()/.eq()usesas neverwith 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, NOTpmv2_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 inweb/src/lib/pmv2-bets.ts.
- conviction =
clamp((initial_value − avg_lost_bet_size) / (avg_won_bet_size − avg_lost_bet_size), 0, 1)0when either avg is null, or whenavg_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, elseVLOW. - 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)→ computebet_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-traderscore,avg_won_bet_size,avg_lost_bet_size, ranks),pmv2_leaderboard_entries(names viabuildWalletNameMap),pmv2_positions(the live position snapshot — NOT run-scoped, see gotcha),pmv2_event_category(category labels, joined onevent_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 viaBAND_TONE→StatusBadge.
Gotchas & reusable lessons
pmv2_positionsis NOT run-scoped — it's a live snapshot keyed byproxy_walletUnlike
pmv2_traders(PK includesrun_id),pmv2_positionsis a live snapshot keyed byproxy_walletwith norun_id. To assemble a cohort’s bets you must join positions to the latestpmv2_tradersrun byproxy_wallet— do not assume positions belong to a run. This is the single most important join in the hook.
pmv2_tradershas NO name columns — names come frompmv2_leaderboard_entriesSame 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_categoryjoins onevent_slug, NOTevent_idCategory labels come from
pmv2_event_categorykeyed byevent_slug. Joining onevent_idsilently yields no category. Use the slug.
StatusBadge API —
tone+labelprops, tones are a fixed set
StatusBadgetakestone+labelprops (NOTchildren). Valid tones are exactlyamber | emerald | rose | gray | sky.BAND_TONEmaps each band to one of these — keep the map inside that set or the badge renders untoned.
New
pmv2_*tables absent fromdatabase.types.ts
pmv2_positionsandpmv2_event_category(like the otherpmv2_*tables) are not in generated types → hook usesas neveron.from()/.eq()with explicit row generics. Same precedent as trader-scout-dashboard. Cleaner follow-up: hand-add thepmv2_*tables topackages/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.0925→ VHIGH, 5 traders, ~$220K cohort stake, cohort entry0.711 → 0.835. Use this to sanity-check the reducer after any math change.
If you touch this next
- Treat the scoring math as a cross-agent contract —
web/src/lib/pmv2-bets.tsmirrors @code’smodel.rs. If results drift, re-diff against the Rust before “fixing” the TS. - Confirm the positions join —
pmv2_positionsis live + wallet-keyed; always join to the latest completed run’spmv2_tradersbyproxy_wallet. - Hand-add
pmv2_*tables todatabase.types.tsto drop theas nevercasts across the Bet View + Self Bets + Trade Analyzer + Scout hooks. - All three sub-views share
BetsDashboard.tsx(shell + sub-nav) and the domain moduleweb/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 tablesAs of the 2026-06-15 fix, Self Bets no longer reads
pm_runs/pm_positionsat 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)
pm_self_wallets→ the operator’s tracked wallets (the only snapshot/CRM table still read).- For each wallet,
GET https://data-api.polymarket.com/positions?user=<wallet>&sizeThreshold=0&limit=500directly from the browser SPA (CORS-open, no proxy/edge fn).sizeThreshold=0is required (default1hides small positions).- Map each camelCase row via
parsePolymarketPosition→SelfPosition(now carries aredeemablefield).- Cohort backing as before: match
(condition_id, outcome_index)against the Bet-View cohort (attachCohortBacking, reusinguseCohortBets).- 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=0on 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$0settled) 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)
attachCohortBackingmatches a self position to a cohort bet viacohortBetKey=(condition_id, outcome_index)— the same group key Bet View aggregates on. ReusesuseCohortBets, 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— readspm_self_wallets→ per-wallet live data-api fetch →parsePolymarketPosition; merges cohort backing fromuseCohortBets. Per-wallet fetch failures fall back to[]; the wallet-list query error still throws to the ErrorBanner. - UI:
SelfBetsView(inweb/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(commitafd8740) — NOT yet pushed, NOT deployedThe 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:
- The external polymarket-fetch pmv2
self_snapshotscraper writes ZERO redeemable rows topm_positionsfor 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. - The CRM page also filtered them out:
useSelfBets.tshad.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=0is REQUIRED — the default is1, 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, …) → newparsePolymarketPositionmapper;SelfPositiongained aredeemablefield.
Consequence — the
self_snapshotagent now produces snapshots nobody readsThis page no longer touches
pm_runs.selector='self_snapshot'/pm_positions/latestSelfRunIds. The external pmv2self_snapshotagent (polymarket-fetch) is therefore producing snapshots no CRM surface consumes — a candidate to retire (operator’s call). The snapshot helpers stay exported inpmv2-bets.tsbut 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 SelfBets → 54 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
- Input →
normalizeSlug: sanitize to[a-z0-9-]only.- Match
pmv2_positionsrows whereevent_slugORslugequals the normalized slug, held & non-dust.- Join the latest
pmv2_traders(forscore+ conviction inputs).consolidateGauge→ per-outcome gauge sides (the ported Rust).
normalizeSlugsanitizing to[a-z0-9-]also closed a PostgREST.or()injection vectorThe slug is interpolated into a PostgREST
.or(event_slug.eq.<slug>,slug.eq.<slug>)filter. Sanitizing the input to only[a-z0-9-](whichnormalizeSlugdoes 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/consolidateGaugeinweb/src/lib/pmv2-bets.tsmirror @code’sis_date_token/event_merge_key/consolidate_gaugeRust. 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,jundrop), - a year in
2024–2031inclusive, - an all-digit token,
- a digit group matching
\d+[-/]\d+(e.g.5/12,3-4).
- a month name — full name OR 3-letter prefix (so
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 intohidden_sidesrendered as “+N more”, n_markets= count of distinctcondition_idfolded into the event,- label = the modal
event_slug(most common raw slug among the grouped positions).
- group sides by
Layers
- Domain (
web/src/lib/pmv2-bets.ts):isDateToken,eventMergeKey,normalizeSlug,consolidateGauge. - Hook:
useAnalyzerEvent— normalize → matchpmv2_positionsbyevent_slugORslug(held, non-dust) → join latestpmv2_traders→consolidateGauge. - UI:
TradeAnalyzer(inweb/src/pages/polymarket/bets/) — paste box + per-outcome gauge sides (capped at 8, “+N more” overflow).
Related
- trader-scout-dashboard — the Scout tab; ranks traders, Bet View ranks bets; shares the
pmv2_*tables, thebuildWalletNameMapenrichment, and theas never/supabaseQueryprecedent - 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 intopolymarket_fetch/CRM_DASHBOARD_PROMPT.mdper @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 thepmv2_*tables and the canonical scoring math; itsself_snapshotagent 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_snapshotscraper never wrote redeemable self rows) - integrations — Polymarket integration index entry
- tech-debt —
database.types.tsfollow-up for thepmv2_*tables