The read-only Paper Trading performance dashboard that replaced the old Polymarket “smart-money consensus + copy-score leaderboard” section. Lives at the same route /polymarket (sidebar label still “Polymarket”, icon TrendingUp). Shipped 2026-05-29 to 2026-05-30, merged FF to master, commit 9670ebd. Extended 2026-05-30 (commit bea641a) with a top-trader benchmark line, an asset × side heatmap, and an hourly ROI strip — see the extension section.

Demoted 2026-06-01 — this is now the "Longshot" secondary tab

As of commit bf66a6f, /polymarket is a tab shell whose default tab is the new copy-lane-dashboard. This dashboard was moved to web/src/pages/polymarket/longshot/LongshotDashboard.tsx, deep-linked via ?tab=longshot, and now excludes lane='copy' rows.

For Agents

  • Route: /polymarket — page at web/src/pages/polymarket/index.tsx (+ components/).
  • Domain core: web/src/lib/paper-trades.ts (pure, framework-free, 25 unit tests).
  • Data hook: web/src/lib/hooks/polymarket/usePaperTrades.ts (TanStack Query, pm_paper_trades, refetchInterval: 30_000).
  • Data source: public.pm_paper_trades in the CRM’s single Supabase project (mkofmdtdldxgmmolxxhc, internally named “mgmt”). Use useSupabase() — there is no separate mgmt client.
  • Read-only. No mutations, no writes — the bets are produced by an external strategy pipeline.

What changed

The previous Polymarket section (consensus / traders / markets / my-bets / runs / playground pages, their components, ~25 hooks, the RunScope/Selector contexts, polymarket-scope) read pm_runs / pm_trader_metrics and surfaced smart-money intersection + a copy_score leaderboard. All of it (~138 files) was deleted and replaced by this dashboard at the same route. The new section is a single performance view over the strategy’s flat-stake paper trades.

"mgmt" is NOT a separate database

Older notes (integrations, polymarket-fetch, earlier LOG entries) described the pm_* tables as living in a separate “mgmt” Supabase project that the CRM consumes externally. That framing is wrong. The Supabase project internally named “mgmt” (mkofmdtdldxgmmolxxhc) IS the CRM’s one and only Supabase — it is what VITE_SUPABASE_URL points at. All pm_* tables live in that single project. There is no second client; read them with the normal useSupabase().

Architecture (three layers)

Follows the repo’s standard Page → hook → Supabase flow but with a pure domain module factored out so all math is unit-testable without React.

1. Domain module — web/src/lib/paper-trades.ts

Pure, framework-free. No React, no Supabase imports. Holds:

  • View-model: RawPaperTrade (DB shape) → parsePaperTrade(raw): PaperTrade. The parse step:
    • Number()-coerces every numeric column (Postgres numeric serializes as strings over PostgREST — see gotcha below).
    • derives asset = market.split('-')[0].
    • derives isOpen, isWin, slippageRatio.
  • Formatters: formatUsd, formatPct, formatCents, formatSignalTime (UTC).
  • Filters: applyFilters, rangeStartMs — pure functions that take an injected nowMs (so they’re deterministic and testable; no hidden Date.now()).
  • Derive functions: deriveKpis, deriveCumulative, deriveByLane, deriveByBand, deriveByPhase.
  • Exported exhaustive buckets: PRICE_BANDS, PHASES (see Exhaustive buckets).

2. Data hook — web/src/lib/hooks/polymarket/usePaperTrades.ts

  • TanStack Query hook over useSupabase().from('pm_paper_trades').
  • Server-side range lower-bound via .gte('signal_wall', …) — the time-range filter is pushed to Postgres, not done client-side.
  • refetchInterval: 30_000 — polls every 30s (the dashboard is a live monitor).

3. Page + components — web/src/pages/polymarket/

index.tsx wires: filters (top strip, URL-synced via useUrlListState) → usePaperTrades hook → derive functions (in useMemo) → view components:

ComponentRole
KpiCards6 headline KPIs (deriveKpis)
CumulativePnlChartrecharts cumulative P&L line (deriveCumulative)
BreakdownTable ×3by lane / price band / phase (deriveByLane/deriveByBand/deriveByPhase)
OpenPositionsTablecurrently-open bets (isOpen)
RecentBetsTablemost recent resolved/open bets
PaperTradeFiltersthe top filter strip
Sideside panel

Data source & semantics

Verified against the live DB.

  • Table: public.pm_paper_trades in project mkofmdtdldxgmmolxxhc (“mgmt” = the CRM’s Supabase).
  • 23 columns — the brief’s documented subset plus 4 extras not in the original spec: signal_ts_ns, up_mid_at_signal, open_btc, final_btc.
  • Flat bet size: every bet stakes $10.
  • Open vs resolved invariant (verified — 0 violations across the live table):
    OPEN  ⟺  up_won IS NULL  ⟺  realized_pnl_usd IS NULL
    
    Use this to classify a row as open; don’t rely on any single column alone.

Strategies / lanes are data-driven

Live strategies in the table:

  • longshot — the one real live strategy: buy ≤10¢ late-window mean-reversion. The bulk of the data.
  • btc_spillover — tiny volume.
  • momentumabsent from live data.

Lanes are derived from the data, never hardcoded

Because momentum doesn’t appear and volumes vary, the lane breakdown (deriveByLane) is built from whatever strategy values exist in the result set. Do not hardcode the lane list — a new strategy should appear automatically.

Key decisions & gotchas

Exhaustive buckets (out-of-config data)

The brief claimed there were no resolved longshot bets below the 5¢ price floor or past the 0.93 time-in-window cap. The live data disagreed:

  • 98 resolved bets below the 5¢ floor.
  • 39 above the 0.93 cap.
  • time_in_window actually spans 0.09 – 0.9967; entry price spans 0.01 – 0.57.

These are pre-tuning bets from before the strategy’s config was tightened. Rather than drop them (which would silently understate volume and P&L), the buckets were made exhaustive with explicit out-of-config catch-alls:

DimensionBuckets (PRICE_BANDS / PHASES)
Price band<0.05 · 0.05–0.07 · 0.07–0.10 · ≥0.10
Phase (time-in-window)<0.66 · 0.66–0.80 · 0.80–0.93 · ≥0.93

The out-of-config buckets (<0.05 price, ≥0.93 phase, etc.) are shown, muted as “off-config” — not hidden. Every bet lands in exactly one bucket; the breakdown totals always reconcile with the KPI totals.

react-hooks/purity: Date.now() in render

Lint gotcha — Date.now() inside useMemo/render is flagged

The react-hooks/purity rule treats Date.now() called during render (including inside a useMemo) as an impurity and errors. The derive/filter functions need a “now” for the time-range lower bound, so the page reads it once via a lazy useState initializer (impure calls are allowed there):

const [nowMs] = useState(() => Date.now())

nowMs is then threaded into applyFilters / rangeStartMs as a plain argument. This is also why those domain functions take nowMs as a parameter instead of calling Date.now() themselves.

tsc -b is the CI-relevant check

The repo’s CI uses tsc -b (project build mode), which is stricter than tsc --noEmit. Always verify with cd web && npx tsc -b before pushing — --noEmit can pass while -b fails. (Same caveat documented in TypeScript Strictness.)

Pre-existing baseline reds on master (NOT from this work)

When verifying, these failures already exist on master and are unrelated to the dashboard — attribute regressions by file before blaming this change:

  • tsc -b errors on web/src/sw.ts:69 (waitUntil/Event typing).
  • web/src/mobile/hours/MobileHours.test.tsx fails (the long-standing failing test).
  • pnpm --filter web lint reports ~115 errors across unrelated files.

Process

Built via the superpowers workflow: brainstorm → spec → plan → subagent-driven TDD execution with a two-stage review per task. 21 commits, then squash/FF-merged to master as 9670ebd.

  • Spec: docs/superpowers/specs/2026-05-29-paper-trading-dashboard-design.md
  • Plan: docs/superpowers/plans/2026-05-29-paper-trading-dashboard.md

Extension — top-trader benchmark + asset/side heatmap + hourly ROI strip (2026-05-30)

Three additions to the /polymarket dashboard, shipped 2026-05-30, merged to master as commit bea641a. They turn the page from “how is our flat-stake strategy doing” into “how much edge is sitting in the band that we’re not capturing.”

For Agents

  • New benchmark hook: useTopTraderBenchmark(range) in web/src/lib/hooks/polymarket/ — TanStack Query over the new view public.top_trader_tuned_trades, refetchInterval: 30_000, .limit(5000), drops non-finite timestamps.
  • New DB view: public.top_trader_tuned_trades — read-only, security_invoker = true, granted to authenticated, anon. Migration web/supabase/migrations/20260530000100_top_trader_tuned_trades.sql.
  • All new math lives in the pure module web/src/lib/paper-trades.ts (still framework-free): runningCumulative, benchmarkPnl, parseBenchmarkTrade, mergeSeries, coreStats, deriveByAssetSide, deriveHourly, formatUsdCompact.
  • The $10 normalization lives in app code, not SQL — the view exposes raw price/won/pnl; the frontend recomputes the normalized-to-our-stake P&L.

What was added

FeatureComponentDerive fnWhat it shows
Top-trader benchmark lineCumulativePnlChart (refactored)benchmarkPnl + mergeSeriesA red cumulative line for the reference wallet, normalized to our flat $10 stake, on the same time axis + shared 0-origin as our line
Asset × Side heatmapnew panelderiveByAssetSidePer (asset × buy_up/buy_down): n / win% / ROI% / P&L, tone-coded
Hourly ROI stripnew panelderiveHourlyContiguous wall-clock-hour bars colored by ROI sign, height ∝ |ROI|

1. Top-trader benchmark line

A red line on the cumulative P&L chart tracking wallet 0x75cc3b63a2f2423085e10706c78b494017b93ce1 (a tuned top-trader in the same late-window ≤10¢ regime as our longshot lane).

Normalized to our stake. Their actual stakes are ~10. Plotting both raw on one axis is a ~7× scale mismatch that makes vertical comparison meaningless. So per resolved benchmark trade the P&L is recomputed as if we’d staked $10:

benchmarkPnl = won ? 10 * (1 / price − 1) : −10

Live this shows the reference wallet at ~+430–600 over the same window → the headline insight: there is substantial uncaptured edge sitting in the band we’re already trading.

It's a relative-edge reference, not a 1:1 replay

A caveat footnote on the chart notes that the benchmark’s trade timestamps and selection differ from ours — it is not a bet-for-bet replay. It answers “what would this trader’s edge look like at our sizing,” not “what would we have made copying them exactly.”

2. Asset × Side heatmap

A grid over every (asset × side) cell where side ∈ {buy_up, buy_down}, each showing n / win% / ROI% / P&L. Tone:

  • red when win% < 5 and n ≥ 5
  • green when win% ≥ 11 and n ≥ 5
  • neutral otherwise (incl. low-n cells, which are never colored as a verdict)

Surfaces the regime split: buy_up is essentially dead (~2% win) while buy_down carries the edge (~11.5% win). The n ≥ 5 gate stops a 1-of-2 cell from flashing green/red on noise.

3. Hourly ROI strip

Contiguous bars, one per wall-clock hour across the range (gaps filled so the time axis stays honest), colored by ROI sign with height proportional to |ROI|. Purpose: spot loss-clustering / cliffs — a band of red hours that a daily/weekly aggregate would smear away.

DB: new view public.top_trader_tuned_trades

Read-only view over pm_trades ⋈ pm_market_resolutions. Filters that define “a tuned trade by the reference wallet”:

FilterValue
wallet0x75cc3b63a2f2423085e10706c78b494017b93ce1
sideBUY
slugLIKE '%-updown-5m-%'
price0.05 – 0.10
time_in_window0.66 – 0.93, computed as (epoch(trade_time) − split_part(slug,'-',4)::bigint) / 300
resolvedtrue

Exposes price, won, and the raw pnl — the $10 normalization is deliberately not in SQL (see Key decisions below). 394 rows live.

Verify RLS readability by simulating the role

The view is security_invoker = true and granted to authenticated + anon. Because security_invoker makes the querying role’s RLS on the base tables apply, readability was verified by simulating the role, not assumed:

set local role authenticated;
select count(*) from public.top_trader_tuned_trades;  -- 394

The base tables’ *_auth_read policies (USING (true)) let authenticated read through. Applied via the Supabase MCP apply_migration, then tracked at web/supabase/migrations/20260530000100_top_trader_tuned_trades.sql.

Architecture (where the new code lives)

Same three-layer shape as the original dashboard — all math stays in the pure, unit-tested web/src/lib/paper-trades.ts:

  • runningCumulative — extracted out of the original deriveCumulative so both our series and the benchmark series share one running-total implementation.
  • benchmarkPnl / parseBenchmarkTrade — the $10-normalized P&L formula + the view-row parser (same numeric-as-string coercion discipline as parsePaperTrade).
  • mergeSeries — carries both series forward across the union timeline with a shared 0-origin, so the chart can render a “gap” tooltip (their cumulative minus ours at any instant).
  • coreStats — extracted from the original summarize; now shared by summarize and deriveByAssetSide (DRY — one win%/ROI/P&L reducer).
  • deriveByAssetSide, deriveHourly, formatUsdCompact — the heatmap, hourly strip, and compact axis formatter.
  • useTopTraderBenchmark(range) — the second data hook (30s poll, .limit(5000), drops non-finite ts).
  • CumulativePnlChart — refactored from a single-line categorical chart to a numeric/time x-axis with two lines plus distinct benchmark legend states (below).

Key decisions & lessons (2026-05-30)

Normalize a benchmark to your stake before comparing on a shared axis

Plotting the reference wallet’s actual ~10 on one axis is a ~7× scale mismatch — vertical comparison becomes meaningless and the chart lies by construction. Recomputing their P&L at our $10 sizing is the honest “their edge at our sizing” view. Keep a caveat that timestamps/selection still differ (relative-edge reference, not a replay).

A second data series feeding a chart must surface its OWN loading/error/empty states

Silent-feature-failure pattern. If the benchmark query’s failure or emptiness renders identically to “no data,” the comparison just silently disappears on a monitoring tool and no one notices the feature broke. Fix: render distinct legend chipsunavailable / loading / @ $10 / no-trades-in-range — and decouple the chart skeleton from the benchmark’s loading so our primary line never blocks on the secondary query.

Unbounded Supabase queries can silently truncate a cumulative line

Without an explicit .limit(), PostgREST’s default row cap can clip the result mid-range, flattening the cumulative line and inverting the takeaway. The benchmark hook sets .limit(5000). Latent follow-up: usePaperTrades (our own primary hook) is still unbounded — same risk lurks there.

Keep sizing assumptions in app code, not SQL

The view returns raw price/won/pnl; the $10 normalization is a frontend concern (benchmarkPnl). Sizing is a product decision that should be cheap to change without a migration — so it lives in paper-trades.ts, not the view definition.

Verify a new view's RLS readability by simulating the role

set local role authenticated; select count(*) … rather than trusting the grant. From the client, “empty result” and “RLS filtered everything out” are indistinguishable — only simulating the role disambiguates.

Process

Same superpowers loop: brainstorm → spec (validated live: no join fan-out on pm_trades ⋈ pm_market_resolutions, payout is a clean 0/1 flag) → plan → subagent-driven TDD (per-task two-stage review) → a 4-perspective verification pass → fix → merge. The verification pass is what caught the three landmines above — the silent benchmark-failure, the row-cap truncation, and a Y-axis clip issue.

  • Spec: docs/superpowers/specs/2026-05-30-top-trader-benchmark-asset-side-design.md
  • Plan: docs/superpowers/plans/2026-05-30-top-trader-benchmark-asset-side.md
  • integrations — External integrations index (note: its “mgmt is a separate project” framing for pm_* is superseded by this note)
  • polymarket-fetch — The external strategy pipeline that produces the bets (dangling/planned note)
  • agent-context-crm — Data-layer pattern, tsc -b strictness, hook factories
  • security — CF Access ZTNA → Supabase JWT (how useSupabase() is authenticated)
  • dry-refactor-2026-05-11useUrlListState and the shared list-page primitives reused here
  • tech-debt — Known technical debt