diff --git a/.github/workflows/leaderboard-stats.yml b/.github/workflows/leaderboard-stats.yml new file mode 100644 index 0000000..21a2f50 --- /dev/null +++ b/.github/workflows/leaderboard-stats.yml @@ -0,0 +1,51 @@ +name: leaderboard-stats + +# Precomputes per-wallet win-rate + recent realized P&L for the leaderboard set +# and commits ui/public/leaderboard-stats.json. Polymarket's public leaderboard +# API only exposes all-time profit/volume per wallet; win-rate + sample size +# need each wallet's /trades history — an ~80-wallet fan-out that doesn't belong +# in a per-render client fetch, so it's batched here every 6h and served static. +# The script skips the write (no commit, no redeploy) when stats are unchanged. + +on: + schedule: + - cron: "0 */6 * * *" + workflow_dispatch: {} # manual trigger from the Actions tab + +permissions: + contents: write + +concurrency: + group: leaderboard-stats + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 1 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Compute leaderboard stats + working-directory: ui + run: npx -y tsx@4 scripts/build-leaderboard-stats.ts + + - name: Commit if changed + run: | + git config user.name "auspex-bot" + # GitHub-attributable noreply so Vercel accepts the auto-deploy this + # commit triggers (same as data-refresh.yml). + git config user.email "96061296+gorillachimps@users.noreply.github.com" + if git diff --quiet ui/public/leaderboard-stats.json; then + echo "No changes — exiting clean." + exit 0 + fi + git add ui/public/leaderboard-stats.json + git commit -m "data: refresh leaderboard stats ($(date -u +%H:%MZ))" + git push diff --git a/ui/app/api/holders/route.ts b/ui/app/api/holders/route.ts new file mode 100644 index 0000000..1695ffe --- /dev/null +++ b/ui/app/api/holders/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; + +export const revalidate = 120; + +const GAMMA = "https://gamma-api.polymarket.com"; +const DATA = "https://data-api.polymarket.com"; +const COND_RE = /^0x[0-9a-fA-F]{64}$/; +const SLUG_RE = /^[a-z0-9-]{1,120}$/i; +const ADDR_RE = /^0x[0-9a-fA-F]{40}$/; +const HOLDERS_LIMIT = 12; + +type Holder = { proxyWallet: string; name: string; amount: number }; +type Group = { token: string; outcomeIndex: number; holders: Holder[] }; + +/** + * Server-side Top Holders resolver. The Polymarket /holders endpoint keys on a + * CTF conditionId, which our snapshot doesn't carry — so we resolve it from the + * market slug via gamma-api, then fetch holders from data-api. Composed server- + * side (one round-trip for the client, cacheable, no CORS hop). + * + * gamma-api occasionally returns unescaped control chars in description fields, + * which breaks JSON.parse — so we regex-extract the conditionId from the raw + * text rather than parsing the whole payload. + */ +async function resolveConditionId(slug: string): Promise { + const r = await fetch(`${GAMMA}/markets?slug=${encodeURIComponent(slug)}`, { + cache: "no-store", + }); + if (!r.ok) return null; + const text = await r.text(); + const m = text.match(/"conditionId"\s*:\s*"(0x[0-9a-fA-F]{64})"/); + return m ? m[1] : null; +} + +function cleanGroups(value: unknown): Group[] { + if (!Array.isArray(value)) return []; + const groups: Group[] = []; + for (const item of value) { + if (!item || typeof item !== "object") continue; + const rec = item as Record; + const token = typeof rec.token === "string" ? rec.token : ""; + const outcomeIndex = + typeof rec.outcomeIndex === "number" ? rec.outcomeIndex : 0; + const rawHolders = Array.isArray(rec.holders) ? rec.holders : []; + const holders: Holder[] = []; + for (const h of rawHolders) { + if (!h || typeof h !== "object") continue; + const hr = h as Record; + const proxyWallet = + typeof hr.proxyWallet === "string" ? hr.proxyWallet : ""; + if (!ADDR_RE.test(proxyWallet)) continue; + const amount = + typeof hr.amount === "number" ? hr.amount : Number(hr.amount); + if (!Number.isFinite(amount) || amount <= 0) continue; + const pseudonym = typeof hr.pseudonym === "string" ? hr.pseudonym : ""; + const name = typeof hr.name === "string" ? hr.name : ""; + holders.push({ proxyWallet, name: pseudonym || name || "", amount }); + } + if (holders.length > 0) groups.push({ token, outcomeIndex, holders }); + } + return groups; +} + +export async function GET(request: Request) { + const url = new URL(request.url); + const slug = url.searchParams.get("slug"); + const marketParam = url.searchParams.get("market"); + + let conditionId: string | null = null; + if (marketParam && COND_RE.test(marketParam)) { + conditionId = marketParam; + } else if (slug && SLUG_RE.test(slug)) { + conditionId = await resolveConditionId(slug); + } else { + return NextResponse.json( + { error: "expected ?slug= or ?market=" }, + { status: 400 }, + ); + } + + if (!conditionId) { + return NextResponse.json( + { conditionId: null, groups: [] }, + { headers: { "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300" } }, + ); + } + + let groups: Group[] = []; + try { + const hr = await fetch( + `${DATA}/holders?market=${conditionId}&limit=${HOLDERS_LIMIT}`, + { cache: "no-store" }, + ); + if (hr.ok) groups = cleanGroups(JSON.parse(await hr.text())); + } catch { + // upstream hiccup → empty result; the client shows an empty state + } + + return NextResponse.json( + { conditionId, groups }, + { headers: { "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300" } }, + ); +} diff --git a/ui/app/globals.css b/ui/app/globals.css index b16c5f0..cbd17eb 100644 --- a/ui/app/globals.css +++ b/ui/app/globals.css @@ -8,7 +8,7 @@ --border-strong: #2a3142; --foreground: #e6e8ee; --muted: #8a91a3; - --muted-2: #5d6478; + --muted-2: #7a8195; --accent: #a78bfa; --accent-strong: #8b5cf6; --positive: #34d399; diff --git a/ui/app/markets/[slug]/page.tsx b/ui/app/markets/[slug]/page.tsx index 918b6a8..f420bdd 100644 --- a/ui/app/markets/[slug]/page.tsx +++ b/ui/app/markets/[slug]/page.tsx @@ -17,6 +17,7 @@ import { TradeSizeDistribution } from "@/components/TradeSizeDistribution"; import { ShareButtons } from "@/components/ShareButtons"; import { TriggerAlertButton } from "@/components/TriggerAlertButton"; import { DisqusComments } from "@/components/DisqusComments"; +import { TopHolders } from "@/components/TopHolders"; import { cn } from "@/lib/cn"; import { getMarketBySlug } from "@/lib/data"; import { @@ -223,13 +224,44 @@ export default async function MarketDetailPage({ params, searchParams }: Props) /> - +
+ +
+ +

{summarizeRules(row)} {row.liveState === "deferred" && row.liveReason ? ( ({row.liveReason}) ) : null}

+
+ + + +
+

+ Outcomes are finalized by Polymarket via UMA's optimistic + oracle: a proposed result can be challenged during a dispute window + before it settles. {resolutionStateNote(row)} Auspex doesn't + control resolution — confirm the live oracle status on{" "} + + Polymarket + + . +

@@ -321,6 +353,24 @@ function Card({ ); } +function ResolveFact({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +/** Honest, data-derived resolution-state note. We surface the UMA *mechanism* + * and the lifecycle phase inferred from the close date — we deliberately do + * NOT invent a specific dispute status we can't verify on-chain here. */ +function resolutionStateNote(row: { endDate: string | null }): string { + return urgencyForEnd(row.endDate) === "ended" + ? "Trading has closed; it's now in the resolution / arbitration window." + : "It's still open for trading until the deadline above."; +} + /** Build the pre-filled text for share-to-X / Farcaster. Includes the market * question and a short factoid line so a casual viewer can grasp the bet * without clicking through. */ diff --git a/ui/app/wallets/[address]/page.tsx b/ui/app/wallets/[address]/page.tsx index ba4e0be..77ef7a5 100644 --- a/ui/app/wallets/[address]/page.tsx +++ b/ui/app/wallets/[address]/page.tsx @@ -11,12 +11,21 @@ import { WalletTradesView } from "@/components/WalletTradesView"; import { WalletPnLChart } from "@/components/WalletPnLChart"; import { useUserPositions } from "@/lib/useUserPositions"; import { useWalletTrades } from "@/lib/useWalletTrades"; -import { computeWalletPnl } from "@/lib/walletPnl"; +import { computeWalletPnl, deriveTraderTags, type TraderTag } from "@/lib/walletPnl"; import { shortAddress } from "@/lib/resolveWallet"; import { cn } from "@/lib/cn"; type Props = { params: Promise<{ address: string }> }; +const TAG_TONE: Record = { + sharp: "bg-accent/15 text-accent ring-accent/40", + whale: "bg-fuchsia-500/15 text-fuchsia-200 ring-fuchsia-400/40", + good: "bg-emerald-500/15 text-emerald-200 ring-emerald-400/40", + active: "bg-sky-500/15 text-sky-200 ring-sky-400/40", + caution: "bg-amber-500/15 text-amber-200 ring-amber-400/40", + bad: "bg-rose-500/15 text-rose-200 ring-rose-400/40", +}; + export default function WalletDetailPage({ params }: Props) { const { address } = use(params); const normalised = address.toLowerCase(); @@ -31,6 +40,11 @@ export default function WalletDetailPage({ params }: Props) { return computeWalletPnl(tradesState.trades, positionsState.positions); }, [tradesState.trades, positionsState.positions]); + const tags = useMemo( + () => (pnl && tradesState.trades ? deriveTraderTags(pnl, tradesState.trades) : []), + [pnl, tradesState.trades], + ); + const [copied, setCopied] = useState(false); useEffect(() => { if (!copied) return; @@ -101,6 +115,26 @@ export default function WalletDetailPage({ params }: Props) {
+ {tags.length > 0 ? ( +
+ + Trader profile + + {tags.map((t) => ( + + {t.label} + + ))} +
+ ) : null} + {/* P&L summary tiles */}
; loading: boolean; error: string | null; }; -const ZERO: State = { entries: null, loading: false, error: null }; +const ZERO: State = { + profit: null, + volume: null, + stats: {}, + loading: true, + error: null, +}; + +function parseList(data: unknown): LbEntry[] { + if (!Array.isArray(data)) return []; + return data + .filter( + (e): e is LbEntry => + !!e && + typeof e === "object" && + typeof (e as LbEntry).proxyWallet === "string" && + typeof (e as LbEntry).amount === "number" && + Number.isFinite((e as LbEntry).amount), + ) + .slice(0, 100); +} + +/** Return on volume = realized profit ÷ notional traded. Coarse efficiency + * proxy (volume double-counts round trips) but a useful "edge per dollar + * traded" read. Null when either side is missing or volume is zero. */ +function returnOnVolume(e: Merged): number | null { + if (e.profit == null || e.volume == null || e.volume <= 0) return null; + return e.profit / e.volume; +} export function LeaderboardView() { const [mode, setMode] = useState("profit"); @@ -45,65 +93,123 @@ export function LeaderboardView() { useEffect(() => { let cancelled = false; - setState({ entries: null, loading: true, error: null }); + setState(ZERO); (async () => { try { - const r = await fetch(`${LB_HOST}/${mode}`, { cache: "no-store" }); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const data = await r.json(); - if (cancelled) return; - if (!Array.isArray(data)) throw new Error("unexpected response shape"); - // Defensively filter to entries we can actually link to / display. - const cleaned: LbEntry[] = data - .filter( - (e): e is LbEntry => - e && - typeof e === "object" && - typeof e.proxyWallet === "string" && - typeof e.amount === "number" && - Number.isFinite(e.amount), - ) - .slice(0, 100); - setState({ entries: cleaned, loading: false, error: null }); - } catch (e) { + const [pr, vr, sr] = await Promise.all([ + fetch(`${LB_HOST}/profit`, { cache: "no-store" }), + fetch(`${LB_HOST}/volume`, { cache: "no-store" }), + // Our CI-built win-rate file. Best-effort — absent before the first + // cron run; the cards just omit win-rate. Never fails the leaderboard. + fetch(`/leaderboard-stats.json`, { cache: "no-store" }).catch(() => null), + ]); + if (!pr.ok || !vr.ok) { + throw new Error(`HTTP ${pr.ok ? vr.status : pr.status}`); + } + const [pd, vd] = await Promise.all([pr.json(), vr.json()]); + let stats: Record = {}; + if (sr && sr.ok) { + try { + const sd = await sr.json(); + if (sd && typeof sd === "object" && sd.stats && typeof sd.stats === "object") { + stats = sd.stats as Record; + } + } catch { + // malformed stats file → skip enrichment, keep the leaderboard + } + } if (cancelled) return; setState({ - entries: null, + profit: parseList(pd), + volume: parseList(vd), + stats, loading: false, - error: (e as Error).message, + error: null, }); + } catch (e) { + if (!cancelled) { + setState({ + profit: null, + volume: null, + stats: {}, + loading: false, + error: (e as Error).message, + }); + } } })(); return () => { cancelled = true; }; - }, [mode]); + }, []); - const sourceUrl = useMemo(() => `${LB_HOST}/${mode}`, [mode]); + // Merge the two leaderboards by wallet, then attach precomputed win-rate. + const byWallet = useMemo(() => { + const m = new Map(); + for (const e of state.profit ?? []) { + m.set(e.proxyWallet, { + proxyWallet: e.proxyWallet, + profit: e.amount, + volume: null, + winRate: null, + closed: 0, + pseudonym: e.pseudonym, + name: e.name, + }); + } + for (const e of state.volume ?? []) { + const cur = m.get(e.proxyWallet); + if (cur) { + cur.volume = e.amount; + cur.pseudonym = cur.pseudonym || e.pseudonym; + cur.name = cur.name || e.name; + } else { + m.set(e.proxyWallet, { + proxyWallet: e.proxyWallet, + profit: null, + volume: e.amount, + winRate: null, + closed: 0, + pseudonym: e.pseudonym, + name: e.name, + }); + } + } + for (const entry of m.values()) { + const s = state.stats[entry.proxyWallet]; + if (s) { + entry.winRate = s.winRate; + entry.closed = s.closedCount; + } + } + return m; + }, [state.profit, state.volume, state.stats]); + + const ranked = useMemo(() => { + const order = mode === "profit" ? state.profit : state.volume; + if (!order) return []; + return order + .map((e) => byWallet.get(e.proxyWallet)) + .filter((m): m is Merged => !!m); + }, [mode, state.profit, state.volume, byWallet]); return ( @@ -175,12 +281,53 @@ function TabButton({ ); } +const TAG_TONE: Record = { + sharp: "bg-accent/15 text-accent ring-accent/40", + whale: "bg-fuchsia-500/15 text-fuchsia-200 ring-fuchsia-400/40", + edge: "bg-emerald-500/15 text-emerald-200 ring-emerald-400/40", + grinder: "bg-sky-500/15 text-sky-200 ring-sky-400/40", + red: "bg-rose-500/15 text-rose-200 ring-rose-400/40", +}; + +function tagsFor(e: Merged): { label: string; tone: string; hint: string }[] { + const tags: { label: string; tone: string; hint: string }[] = []; + const rov = returnOnVolume(e); + if (e.winRate != null && e.closed >= 20 && e.winRate >= 0.5) { + tags.push({ + label: "Sharp", + tone: "sharp", + hint: `${Math.round(e.winRate * 100)}% win rate over ${e.closed} closed positions`, + }); + } + if ((e.volume ?? 0) >= 1_000_000) { + tags.push({ label: "Whale", tone: "whale", hint: "≥ $1M traded all-time" }); + } + if (rov != null && rov >= 0.1 && (e.profit ?? 0) > 0) { + tags.push({ + label: "High edge", + tone: "edge", + hint: `${(rov * 100).toFixed(0)}% return on volume`, + }); + } + if ((e.volume ?? 0) >= 500_000 && rov != null && rov >= 0 && rov < 0.02) { + tags.push({ + label: "Grinder", + tone: "grinder", + hint: "high volume, thin margin per dollar traded", + }); + } + if ((e.profit ?? 0) < 0) { + tags.push({ label: "In the red", tone: "red", hint: "negative realized P&L" }); + } + return tags; +} + function LeaderboardCard({ entry, rank, mode, }: { - entry: LbEntry; + entry: Merged; rank: number; mode: Mode; }) { @@ -194,48 +341,123 @@ function LeaderboardCard({ : rank === 3 ? "bg-orange-500/15 text-orange-200 ring-orange-400/40" : "bg-surface-2 text-muted ring-border"; + const tags = tagsFor(entry); return ( - - {isPodium ? -
-
- {display} -
-
- {entry.proxyWallet.slice(0, 6)}…{entry.proxyWallet.slice(-4)} -
-
-
-
+ 0 - ? "text-emerald-300" - : entry.amount < 0 - ? "text-rose-300" - : "text-foreground" - : "text-foreground", + "grid h-8 w-8 shrink-0 place-items-center rounded-full text-[13px] font-bold tabular ring-1", + rankTone, )} > - {mode === "profit" - ? fmtUSDSignedText(entry.amount) - : fmtUSDCompact(entry.amount)} -
-
- {mode === "profit" ? "Realized P&L" : "Volume traded"} + {isPodium ? ( +
+ +
+ 0 + ? "pos" + : entry.profit < 0 + ? "neg" + : "neutral" + } + /> + + = 10 + ? `${Math.round(entry.winRate * 100)}%` + : "—" + } + tone="neutral" + hint="Profit rate on closed (sold) positions over recent history. Positions held to resolution aren't counted, so this skews low." + /> +
+ + {tags.length > 0 ? ( +
+ {tags.map((t) => ( + + {t.label} + + ))} +
+ ) : null}
); } + +function Metric({ + label, + value, + tone, + highlight, + hint, +}: { + label: string; + value: string; + tone: "pos" | "neg" | "neutral"; + highlight?: boolean; + hint?: string; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} diff --git a/ui/components/LivePmImpliedStat.tsx b/ui/components/LivePmImpliedStat.tsx index 7b3e7cb..5ea2316 100644 --- a/ui/components/LivePmImpliedStat.tsx +++ b/ui/components/LivePmImpliedStat.tsx @@ -75,6 +75,11 @@ export function LivePmImpliedStat({ )} > {effective != null ? fmtImpliedPct(effective) : "—"} + {effective != null ? ( + + ${effective.toFixed(2)}/share + + ) : null}
{bestBid != null && bestAsk != null ? (
diff --git a/ui/components/MarketTable.tsx b/ui/components/MarketTable.tsx index 85fdd73..3b0f9f5 100644 --- a/ui/components/MarketTable.tsx +++ b/ui/components/MarketTable.tsx @@ -35,11 +35,12 @@ type Props = { sorting: SortingState; onSortingChange: (next: SortingState) => void; onClearFilters?: () => void; + density?: "compact" | "default"; }; const columnHelper = createColumnHelper(); -export function MarketTable({ rows, sorting, onSortingChange, onClearFilters }: Props) { +export function MarketTable({ rows, sorting, onSortingChange, onClearFilters, density = "default" }: Props) { const [expanded, setExpanded] = useState>(() => new Set()); const [highlight, setHighlight] = useState(null); const tableContainerRef = useRef(null); @@ -527,7 +528,10 @@ export function MarketTable({ rows, sorting, onSortingChange, onClearFilters }: {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/ui/components/Screener.tsx b/ui/components/Screener.tsx index 1999945..707c842 100644 --- a/ui/components/Screener.tsx +++ b/ui/components/Screener.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useQueryState, parseAsString, parseAsStringLiteral } from "nuqs"; import type { SortingState } from "@tanstack/react-table"; -import { Activity, BookmarkPlus, X } from "lucide-react"; +import { Activity, AlignJustify, BookmarkPlus, SlidersHorizontal, X } from "lucide-react"; import { toast } from "sonner"; import { SubtypeFilter } from "./SubtypeFilter"; import { MarketTable } from "./MarketTable"; @@ -12,7 +12,9 @@ import { TickerChips } from "./TickerChips"; import { KeyboardShortcuts } from "./KeyboardShortcuts"; import { OrderTicket } from "./OrderTicket"; import { SUBTYPE_CHIPS } from "@/lib/families"; +import { cn } from "@/lib/cn"; import { useStarred } from "@/lib/useStarred"; +import { useDensity } from "@/lib/useDensity"; import { useLiveMidMap } from "@/lib/useLiveMarket"; import { useSavedFilters } from "@/lib/useSavedFilters"; import type { Family, TableRow } from "@/lib/types"; @@ -42,6 +44,29 @@ type Props = { const DEFAULT_SORT = "days:asc"; +// One-tap "quick screens" — curated starter views built from the sorts the +// table already supports (column ids: delta, delta24h, depth, rc, volume24h, +// days). Lowers the cold-start cost of the screener for newcomers without +// adding any new query surface. `live` arms the existing "Live only" toggle. +const QUICK_SCREENS: { label: string; sort: string; live?: boolean; hint: string }[] = [ + { label: "Closing soon", sort: "days:asc", hint: "Ending soonest first — short-dated bets" }, + { label: "Near trigger", sort: "delta:asc", live: true, hint: "Closest to crossing their resolution line" }, + { label: "Biggest movers", sort: "delta24h:desc", hint: "Largest 24h odds shifts" }, + { label: "Deepest books", sort: "depth:desc", hint: "Most order-book liquidity to size into" }, + { label: "High clarity", sort: "rc:desc", live: true, hint: "Cleanest resolution path (Clarity score)" }, + { label: "Most traded", sort: "volume24h:desc", hint: "Busiest markets by 24h volume" }, + { label: "Most competitive", sort: "competitive", hint: "Closest to a 50/50 coin-flip" }, +]; + +// Numeric MIN/MAX screener filters. Keys index the local filter state; all map +// to fields already on TableRow, so this is pure client-side filtering. +const FILTER_FIELDS = [ + { key: "minVol", label: "Min 24h vol ($)", ph: "e.g. 5000" }, + { key: "minDepth", label: "Min depth ($)", ph: "e.g. 1000" }, + { key: "maxDist", label: "Max distance (%)", ph: "e.g. 5" }, + { key: "minRc", label: "Min clarity", ph: "0–100" }, +] as const; + function parseSort(s: string): SortingState { if (!s) return []; const idx = s.lastIndexOf(":"); @@ -68,9 +93,28 @@ export function Screener({ rows }: Props) { const [liveFlag, setLiveFlag] = useQueryState("live", liveParser); const { starred } = useStarred(); + const [density, setDensity] = useDensity(); const isStarredOn = starredFlag === "1"; const isLiveOn = liveFlag === "1"; + const [showFilters, setShowFilters] = useState(false); + const [numFilters, setNumFilters] = useState({ + minVol: "", + minDepth: "", + maxDist: "", + minRc: "", + }); + const nf = useMemo( + () => ({ + minVol: parseFloat(numFilters.minVol), + minDepth: parseFloat(numFilters.minDepth), + maxDist: parseFloat(numFilters.maxDist), + minRc: parseFloat(numFilters.minRc), + }), + [numFilters], + ); + const numFilterCount = Object.values(numFilters).filter((v) => v.trim() !== "").length; + const liveCount = useMemo( () => rows.filter((r) => r.liveState === "live").length, [rows], @@ -97,7 +141,16 @@ export function Screener({ rows }: Props) { }); }, [rows, liveMids]); - const sorting = useMemo(() => parseSort(sortParam), [sortParam]); + // "competitive" is an alternate sort that isn't a table column (closeness to + // 50/50). In that mode we pre-sort the rows ourselves and hand the table an + // empty SortingState — both the desktop table (react-table) and the mobile + // list preserve input order when sorting is empty. Clicking any column header + // sets a real sort and exits competitive mode naturally. + const isCompetitive = sortParam === "competitive"; + const sorting = useMemo( + () => (isCompetitive ? [] : parseSort(sortParam)), + [sortParam, isCompetitive], + ); const setSorting = (next: SortingState) => { setSortParam(serializeSort(next), { shallow: true }); }; @@ -152,9 +205,28 @@ export function Screener({ rows }: Props) { if (isStarredOn && !starred.has(r.id)) return false; if (isLiveOn && r.liveState !== "live") return false; if (q && !r.question.toLowerCase().includes(q)) return false; + // Numeric range filters (blank input parses to NaN = inactive). + if (!Number.isNaN(nf.minVol) && r.volume24h < nf.minVol) return false; + if (!Number.isNaN(nf.minDepth) && (r.liquidity ?? 0) < nf.minDepth) return false; + if (!Number.isNaN(nf.maxDist)) { + if (r.liveState !== "live" || r.distancePct == null) return false; + if (Math.abs(r.distancePct * 100) > nf.maxDist) return false; + } + if (!Number.isNaN(nf.minRc) && (r.rc ?? -1) < nf.minRc) return false; return true; }); - }, [rowsWithLive, active, ticker, isStarredOn, starred, isLiveOn, search]); + }, [rowsWithLive, active, ticker, isStarredOn, starred, isLiveOn, search, nf]); + + // Apply the "competitive" (closeness-to-50/50) pre-sort when active; markets + // with no implied price sink to the bottom. + const displayRows = useMemo(() => { + if (!isCompetitive) return filtered; + const dist = (r: TableRow) => + r.impliedYes == null + ? Number.POSITIVE_INFINITY + : Math.abs(r.impliedYes - 0.5); + return [...filtered].sort((a, b) => dist(a) - dist(b)); + }, [filtered, isCompetitive]); const showTickerRow = (active === "all" || active === "binance_price") && tickerOptions.length > 0; @@ -163,6 +235,7 @@ export function Screener({ rows }: Props) { ticker !== "" || isStarredOn || isLiveOn || + numFilterCount > 0 || search.trim() !== ""; const [ticket, setTicket] = useState<{ @@ -210,12 +283,23 @@ export function Screener({ rows }: Props) { toast.success(`Saved "${name.trim()}" to your watchlists.`); } + const applyQuickScreen = useCallback( + (p: (typeof QUICK_SCREENS)[number]) => { + setSortParam(p.sort === DEFAULT_SORT ? null : p.sort, { shallow: true }); + setLiveFlag(p.live ? "1" : null, { shallow: true }); + }, + [setSortParam, setLiveFlag], + ); + const activeQuickScreen = (p: (typeof QUICK_SCREENS)[number]) => + (sortParam || DEFAULT_SORT) === p.sort && !!p.live === isLiveOn; + const resetAll = useCallback(() => { setActive(null, { shallow: true }); setTicker(null, { shallow: true }); setStarredFlag(null, { shallow: true }); setLiveFlag(null, { shallow: true }); setSearch(null, { shallow: true }); + setNumFilters({ minVol: "", minDepth: "", maxDist: "", minRc: "" }); }, [setActive, setTicker, setStarredFlag, setLiveFlag, setSearch]); return ( @@ -250,6 +334,38 @@ export function Screener({ rows }: Props) { ) : null}
+ +
+
+ + Quick screens + + {QUICK_SCREENS.map((p) => { + const on = activeQuickScreen(p); + return ( + + ); + })} +
@@ -303,12 +444,50 @@ export function Screener({ rows }: Props) { onChange={(next) => setTicker(next || null, { shallow: true })} /> ) : null} + {showFilters ? ( +
+
+ {FILTER_FIELDS.map((f) => ( + + ))} +
+ {numFilterCount > 0 ? ( +
+ +
+ ) : null} +
+ ) : null} = 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return n.toFixed(0); +} + +/** + * Top YES/NO holders for a market, via /api/holders (which resolves the CTF + * conditionId from the slug server-side, then hits Polymarket's holders feed). + * Each holder links into the wallet tracker — social proof + a path to follow + * the biggest positions. Amounts are outcome-share counts, not USD. + */ +export function TopHolders({ + slug, + tokenYes, + tokenNo, +}: { + slug: string; + tokenYes: string | null; + tokenNo: string | null; +}) { + const [state, setState] = useState<{ + groups: Group[] | null; + loading: boolean; + error: string | null; + }>({ groups: null, loading: true, error: null }); + + useEffect(() => { + let cancelled = false; + setState({ groups: null, loading: true, error: null }); + fetch(`/api/holders?slug=${encodeURIComponent(slug)}`, { cache: "no-store" }) + .then((r) => + r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)), + ) + .then((d) => { + if (cancelled) return; + setState({ + groups: Array.isArray(d?.groups) ? (d.groups as Group[]) : [], + loading: false, + error: null, + }); + }) + .catch((e) => { + if (!cancelled) + setState({ groups: null, loading: false, error: (e as Error).message }); + }); + return () => { + cancelled = true; + }; + }, [slug]); + + // Label groups YES/NO. Reliable path: match the group's token to the market's + // YES/NO clob token. Fallback (tokens missing/mismatched): array order — + // Polymarket returns outcome 0 (YES) first. We deliberately do NOT trust the + // response's per-group outcomeIndex (observed to read 0 for BOTH outcomes). + const groups = state.groups ?? []; + let yes: Group | undefined; + let no: Group | undefined; + for (const g of groups) { + if (tokenYes && g.token === tokenYes) yes = g; + else if (tokenNo && g.token === tokenNo) no = g; + } + const rest = groups.filter((g) => g !== yes && g !== no); + if (!yes && rest.length) yes = rest.shift(); + if (!no && rest.length) no = rest.shift(); + const hasAny = (yes?.holders.length ?? 0) + (no?.holders.length ?? 0) > 0; + + return ( +
+

+

+ {state.loading ? ( +

Loading holders…

+ ) : state.error ? ( +

+ Couldn't load holders: {state.error} +

+ ) : !hasAny ? ( +

+ No holder data available for this market yet. +

+ ) : ( + <> +
+ + +
+

+ Position size in outcome shares, via Polymarket. Click a holder to + track their wallet. +

+ + )} +
+ ); +} + +function HolderColumn({ + side, + holders, +}: { + side: "YES" | "NO"; + holders: Holder[]; +}) { + const tone = + side === "YES" + ? "text-emerald-300 ring-emerald-400/40 bg-emerald-500/10" + : "text-rose-300 ring-rose-400/40 bg-rose-500/10"; + return ( +
+
+ + {side} + + + {holders.length} {holders.length === 1 ? "holder" : "holders"} + +
+ {holders.length === 0 ? ( +

+ ) : ( +
    + {holders.map((h, i) => ( +
  1. + + {i + 1} + + + {h.name || shortAddress(h.proxyWallet, 6, 4)} + + + {fmtShares(h.amount)} + +
  2. + ))} +
+ )} +
+ ); +} diff --git a/ui/lib/useDensity.ts b/ui/lib/useDensity.ts new file mode 100644 index 0000000..6e73e6d --- /dev/null +++ b/ui/lib/useDensity.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const KEY = "auspex.density.v1"; + +export type Density = "compact" | "default"; + +/** + * Screener row-density preference, persisted to localStorage. Per-tab reactive + * is enough (no cross-tab sync needed) so this stays deliberately minimal — + * not the full reactive-store scaffold the other prefs use. + */ +export function useDensity(): [Density, (d: Density) => void] { + const [density, setDensity] = useState("default"); + + useEffect(() => { + try { + const v = window.localStorage.getItem(KEY); + if (v === "compact" || v === "default") setDensity(v); + } catch { + // ignore privacy-mode / quota errors + } + }, []); + + const set = (d: Density) => { + setDensity(d); + try { + window.localStorage.setItem(KEY, d); + } catch { + // ignore + } + }; + + return [density, set]; +} diff --git a/ui/lib/walletPnl.ts b/ui/lib/walletPnl.ts index 802a0da..729dc29 100644 --- a/ui/lib/walletPnl.ts +++ b/ui/lib/walletPnl.ts @@ -187,3 +187,70 @@ export function computeWalletPnl( wonCount, }; } + +export type TraderTag = { + label: string; + tone: "sharp" | "whale" | "good" | "active" | "caution" | "bad"; + hint: string; +}; + +/** + * Heuristic "smart money" tags from the wallet's recent trade history (capped + * at the ~2500 trades useWalletTrades pulls) + computed P&L. These are coarse + * signals to help triage "is this worth copying", NOT guarantees — sample + * sizes are bounded and the windows are recent-only. Caution tags (New wallet, + * Underwater) are surfaced deliberately so the read isn't one-sided. + */ +export function deriveTraderTags(pnl: WalletPnl, trades: Trade[]): TraderTag[] { + const tags: TraderTag[] = []; + const n = trades.length; + let notional = 0; + let minTs = Number.POSITIVE_INFINITY; + let maxTs = 0; + for (const t of trades) { + const v = t.size * t.price; + if (isFinite(v)) notional += Math.abs(v); + if (isFinite(t.timestamp)) { + if (t.timestamp < minTs) minTs = t.timestamp; + if (t.timestamp > maxTs) maxTs = t.timestamp; + } + } + const winRate = pnl.closedCount > 0 ? pnl.wonCount / pnl.closedCount : 0; + const spanDays = maxTs > minTs ? (maxTs - minTs) / SECONDS_PER_DAY : 0; + + if (pnl.closedCount >= 20 && winRate >= 0.6 && pnl.realizedTotal > 0) { + tags.push({ + label: "Sharp", + tone: "sharp", + hint: `${Math.round(winRate * 100)}% win rate over ${pnl.closedCount} closed trades`, + }); + } + if (notional >= 100_000) { + const k = + notional >= 1_000_000 + ? `$${(notional / 1_000_000).toFixed(1)}M` + : `$${Math.round(notional / 1000)}k`; + tags.push({ label: "Whale", tone: "whale", hint: `~${k} traded (recent history)` }); + } + if (pnl.closedCount >= 5 && pnl.total > 0) { + tags.push({ label: "In profit", tone: "good", hint: "net positive: realized + unrealized" }); + } + if (n >= 500) { + tags.push({ label: "High activity", tone: "active", hint: `${n}+ recent trades` }); + } + if (n > 0 && (n < 10 || (spanDays > 0 && spanDays < 14))) { + tags.push({ + label: "New wallet", + tone: "caution", + hint: "short history / few trades — small sample, treat with caution", + }); + } + if (pnl.closedCount >= 20 && pnl.realizedTotal < 0) { + tags.push({ + label: "Underwater", + tone: "bad", + hint: `realized P&L negative over ${pnl.closedCount} closed trades`, + }); + } + return tags; +} diff --git a/ui/public/.well-known/security.txt b/ui/public/.well-known/security.txt new file mode 100644 index 0000000..6040923 --- /dev/null +++ b/ui/public/.well-known/security.txt @@ -0,0 +1,6 @@ +# Auspex security contact — https://auspex.to +Contact: mailto:security@auspex.to +Expires: 2027-06-01T00:00:00.000Z +Policy: https://auspex.to/security +Preferred-Languages: en +Canonical: https://auspex.to/.well-known/security.txt diff --git a/ui/public/leaderboard-stats.json b/ui/public/leaderboard-stats.json new file mode 100644 index 0000000..dbdb747 --- /dev/null +++ b/ui/public/leaderboard-stats.json @@ -0,0 +1,522 @@ +{ + "generatedAt": "2026-06-01T21:18:15.511Z", + "count": 86, + "stats": { + "0x56687bf447db6ffa42ffe2204a05edaa20f55839": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x1f2dd6d473f3e824cd2f8a89d9c69fb96f6ad0cf": { + "winRate": 0.5, + "closedCount": 2, + "realized30d": 0, + "realizedTotal": 84 + }, + "0x6a72f61820b26b1fe4d956e17b6dc2a1ea3033ee": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x204f72f35326db932158cba6adff0b9a1da95e14": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x2005d16a84ceefa912d4e380cd32e7ff827875ea": { + "winRate": 0, + "closedCount": 3, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x78b9ac44a6d7d7a076c14e0ad518b301b63c6b76": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0xd235973291b2b75ff4070e9c0b01728c520b0f29": { + "winRate": 1, + "closedCount": 2, + "realized30d": 0, + "realizedTotal": 30 + }, + "0x863134d00841b2e200492805a01e1e2f5defaa53": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x8119010a6e589062aa03583bb3f39ca632d9f887": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0xe9ad918c7678cd38b12603a762e638a5d1ee7091": { + "winRate": 0.8, + "closedCount": 5, + "realized30d": 0, + "realizedTotal": 1476715 + }, + "0x94f199fb7789f1aef7fff6b758d6b375100f4c7a": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x885783760858e1bd5dd09a3c3f916cfa251ac270": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x23786fdad0073692157c6d7dc81f281843a35fcb": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0xe90bec87d9ef430f27f9dcfe72c34b76967d5da2": { + "winRate": 0.6, + "closedCount": 5, + "realized30d": 0, + "realizedTotal": 554 + }, + "0xd0c042c08f755ff940249f62745e82d356345565": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x507e52ef684ca2dd91f90a9d26d149dd3288beae": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0xc2e7800b5af46e6093872b177b7a5e7f0563be51": { + "winRate": 0.16666666666666666, + "closedCount": 6, + "realized30d": -138, + "realizedTotal": -26968 + }, + "0x006cc834cc092684f1b56626e23bedb3835c16ea": { + "winRate": 0.107981220657277, + "closedCount": 213, + "realized30d": 1655, + "realizedTotal": 127064 + }, + "0xdc876e6873772d38716fda7f2452a78d426d7ab6": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x9f2fe025f84839ca81dd8e0338892605702d2ca8": { + "winRate": 0.5, + "closedCount": 36, + "realized30d": 22587, + "realizedTotal": 236612 + }, + "0x2a2c53bd278c04da9962fcf96490e17f3dfb9bc1": { + "winRate": 0, + "closedCount": 6, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x16f91db2592924cfed6e03b7e5cb5bb1e32299e3": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0xbddf61af533ff524d27154e589d2d7a81510c684": { + "winRate": 0.078125, + "closedCount": 64, + "realized30d": -5994, + "realizedTotal": -1566 + }, + "0xefbc5fec8d7b0acdc8911bdd9a98d6964308f9a2": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x019782cab5d844f02bafb71f512758be78579f3c": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0xdb27bf2ac5d428a9c63dbc914611036855a6c56e": { + "winRate": 0.1323529411764706, + "closedCount": 68, + "realized30d": 0, + "realizedTotal": 455 + }, + "0x16b29c50f2439faf627209b2ac0c7bbddaa8a881": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0xee613b3fc183ee44f9da9c05f53e2da107e3debf": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x14964aefa2cd7caff7878b3820a690a03c5aa429": { + "winRate": 0.174496644295302, + "closedCount": 149, + "realized30d": 0, + "realizedTotal": -10952 + }, + "0x94a428cfa4f84b264e01f70d93d02bc96cb36356": { + "winRate": 0.2, + "closedCount": 10, + "realized30d": 0, + "realizedTotal": 62907 + }, + "0x9495425feeb0c250accb89275c97587011b19a27": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x17db3fcd93ba12d38382a0cade24b200185c5f6d": { + "winRate": 0.46808510638297873, + "closedCount": 47, + "realized30d": 0, + "realizedTotal": 153926 + }, + "0x033a07b3de5947eab4306676ad74eb546da30d50": { + "winRate": 0.75, + "closedCount": 8, + "realized30d": 0, + "realizedTotal": 9583 + }, + "0x9d84ce0306f8551e02efef1680475fc0f1dc1344": { + "winRate": 0.21238938053097345, + "closedCount": 678, + "realized30d": 27944, + "realizedTotal": 128061 + }, + "0xed2239a9150c3920000d0094d28fa51c7db03dd0": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x36a3f17401e395ef4cb1b7f42bcdb8ab8e15fafb": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0xee00ba338c59557141789b127927a55f5cc5cea1": { + "winRate": 0.11538461538461539, + "closedCount": 52, + "realized30d": 0, + "realizedTotal": 488 + }, + "0xd38b71f3e8ed1af71983e5c309eac3dfa9b35029": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x343d4466dc323b850e5249394894c7381d91456e": { + "winRate": 0, + "closedCount": 1, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x2c335066fe58fe9237c3d3dc7b275c2a034a0563": { + "winRate": 0.23529411764705882, + "closedCount": 17, + "realized30d": 0, + "realizedTotal": 8238 + }, + "0xd7f85d0eb0fe0732ca38d9107ad0d4d01b1289e4": { + "winRate": 0.3333333333333333, + "closedCount": 24, + "realized30d": -32, + "realizedTotal": 357432 + }, + "0x03e8a544e97eeff5753bc1e90d46e5ef22af1697": { + "winRate": 0.6666666666666666, + "closedCount": 3, + "realized30d": 0, + "realizedTotal": 7490 + }, + "0x63ce342161250d705dc0b16df89036c8e5f9ba9a": { + "winRate": 0.12121212121212122, + "closedCount": 297, + "realized30d": 0, + "realizedTotal": -2353 + }, + "0xfe787d2da716d60e8acff57fb87eb13cd4d10319": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x5bffcf561bcae83af680ad600cb99f1184d6ffbe": { + "winRate": 0.40300230946882215, + "closedCount": 866, + "realized30d": 7390, + "realizedTotal": 466369 + }, + "0xb786b8b6335e77dfad19928313e97753039cb18d": { + "winRate": 0.23027522935779818, + "closedCount": 1090, + "realized30d": 0, + "realizedTotal": 29450 + }, + "0xac44cb78be973ec7d91b69678c4bdfa7009afbd7": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x7fb7ad0d194d7123e711e7db6c9d418fac14e33d": { + "winRate": 0, + "closedCount": 4, + "realized30d": 0, + "realizedTotal": 0 + }, + "0xa9878e59934ab507f9039bcb917c1bae0451141d": { + "winRate": 0.09322033898305085, + "closedCount": 708, + "realized30d": 0, + "realizedTotal": -82017 + }, + "0x0b9cae2b0dfe7a71c413e0604eaac1c352f87e44": { + "winRate": 0.30620985010706636, + "closedCount": 934, + "realized30d": -2858, + "realizedTotal": -1204 + }, + "0x6480542954b70a674a74bd1a6015dec362dc8dc5": { + "winRate": 0.2524698133918771, + "closedCount": 911, + "realized30d": -90, + "realizedTotal": -19629 + }, + "0xa61ef8773ec2e821962306ca87d4b57e39ff0abd": { + "winRate": 0.06628571428571428, + "closedCount": 875, + "realized30d": 109, + "realizedTotal": -22490 + }, + "0xd218e474776403a330142299f7796e8ba32eb5c9": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x492442eab586f242b53bda933fd5de859c8a3782": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x9c667a1d1c1337c6dca9d93241d386e4ed346b66": { + "winRate": 0.10010881392818281, + "closedCount": 919, + "realized30d": 6, + "realizedTotal": -16984 + }, + "0x24c8cf69a0e0a17eee21f69d29752bfa32e823e1": { + "winRate": 0.40298507462686567, + "closedCount": 469, + "realized30d": -14372, + "realizedTotal": 65250 + }, + "0xc8ab97a9089a9ff7e6ef0688e6e591a066946418": { + "winRate": 0.07935341660543718, + "closedCount": 1361, + "realized30d": -12084, + "realizedTotal": -12084 + }, + "0x2d27e4d20f3b8a2ee3bc861d9b83752f338676d8": { + "winRate": 0.03224381625441696, + "closedCount": 2264, + "realized30d": 11, + "realizedTotal": -2573 + }, + "0x9f47f1fcb1701bf9eaf31236ad39875e5d60af93": { + "winRate": 0.28125, + "closedCount": 288, + "realized30d": 0, + "realizedTotal": 7082 + }, + "0x2663daca3cecf3767ca1c3b126002a8578a8ed1f": { + "winRate": 0.017135325131810195, + "closedCount": 2276, + "realized30d": 0, + "realizedTotal": -143 + }, + "0xfbfd14dd4bb607373119de95f1d4b21c3b6c0029": { + "winRate": 0.25, + "closedCount": 988, + "realized30d": 623, + "realizedTotal": -5003 + }, + "0xfc25f141ed27bb1787338d2c4e7f51e3a15e1f7f": { + "winRate": 0.23529411764705882, + "closedCount": 187, + "realized30d": 1641, + "realizedTotal": 2365 + }, + "0x4bac379da2f29d87c01ff737843e396a2cec02b1": { + "winRate": 0.043010752688172046, + "closedCount": 558, + "realized30d": 0, + "realizedTotal": -686 + }, + "0x6d3c5bd13984b2de47c3a88ddc455309aab3d294": { + "winRate": 0.0738440303657695, + "closedCount": 1449, + "realized30d": -411, + "realizedTotal": -411 + }, + "0xb4f2f0c858566fef705edf8efc1a5e9fba307862": { + "winRate": 0.09622641509433963, + "closedCount": 530, + "realized30d": 5, + "realizedTotal": 693 + }, + "0xe8dd7741ccb12350957ec71e9ee332e0d1e6ec86": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0xb41ac4e8907de5460e2dac0a98df8d1530d33a25": { + "winRate": 0.1443850267379679, + "closedCount": 187, + "realized30d": 0, + "realizedTotal": -3774 + }, + "0xa58d4f278d7953cd38eeb929f7e242bfc7c0b9b8": { + "winRate": 0.4960380348652932, + "closedCount": 1262, + "realized30d": 10181, + "realizedTotal": 13199 + }, + "0xa5ea13a81d2b7e8e424b182bdc1db08e756bd96a": { + "winRate": 0.4419642857142857, + "closedCount": 224, + "realized30d": 474, + "realizedTotal": 930932 + }, + "0x07e78f5f58f8fa839f298cfe3fefd258883aa343": { + "winRate": 0.1916376306620209, + "closedCount": 861, + "realized30d": 0, + "realizedTotal": -1149 + }, + "0x2785e7022dc20757108204b13c08cea8613b70ae": { + "winRate": 0.24390243902439024, + "closedCount": 41, + "realized30d": -64607, + "realizedTotal": -65899 + }, + "0x55b4af6a1cff148a307cad8ad098206a028e80ea": { + "winRate": 0.04527750730282376, + "closedCount": 2054, + "realized30d": 0, + "realizedTotal": -5130 + }, + "0xed107a85a4585a381e48c7f7ca4144909e7dd2e5": { + "winRate": 0.2975206611570248, + "closedCount": 121, + "realized30d": -6223, + "realizedTotal": -33056 + }, + "0x4133bcbad1d9c41de776646696f41c34d0a65e70": { + "winRate": null, + "closedCount": 0, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x4ce73141dbfce41e65db3723e31059a730f0abad": { + "winRate": 0, + "closedCount": 2499, + "realized30d": 0, + "realizedTotal": 0 + }, + "0x44c1dfe43260c94ed4f1d00de2e1f80fb113ebc1": { + "winRate": 0.3566084788029925, + "closedCount": 401, + "realized30d": -33279, + "realizedTotal": -164624 + }, + "0x5d189e816b4149be00977c1a3c8840374aec4972": { + "winRate": 0.5555555555555556, + "closedCount": 36, + "realized30d": 3197, + "realizedTotal": 18139 + }, + "0xead152b855effa6b5b5837f53b24c0756830c76a": { + "winRate": 0.3125, + "closedCount": 16, + "realized30d": 0, + "realizedTotal": 75722 + }, + "0x63d43bbb87f85af03b8f2f9e2fad7b54334fa2f1": { + "winRate": 0.45454545454545453, + "closedCount": 22, + "realized30d": 0, + "realizedTotal": -372 + }, + "0x4ffe49ba2a4cae123536a8af4fda48faeb609f71": { + "winRate": 0.2388818297331639, + "closedCount": 787, + "realized30d": 419, + "realizedTotal": 419 + }, + "0xd0d6053c3c37e727402d84c14069780d360993aa": { + "winRate": 0.20654911838790932, + "closedCount": 397, + "realized30d": 0, + "realizedTotal": -1176 + }, + "0x751a2b86cab503496efd325c8344e10159349ea1": { + "winRate": 0.4625, + "closedCount": 80, + "realized30d": 4, + "realizedTotal": 112 + }, + "0xfedc381bf3fb5d20433bb4a0216b15dbbc5c6398": { + "winRate": 0.2360655737704918, + "closedCount": 610, + "realized30d": -2174, + "realizedTotal": 14699 + }, + "0xd42f6a1634a3707e27cbae14ca966068e5d1047d": { + "winRate": 0.1244196843082637, + "closedCount": 1077, + "realized30d": 0, + "realizedTotal": -50316 + }, + "0xdd572169e741c72227589c5cb56b9ddd638d694d": { + "winRate": 0.10427350427350428, + "closedCount": 1170, + "realized30d": 0, + "realizedTotal": -2231 + }, + "0x59ee6c6a56d7b00223f0c30f8002c4df762b684d": { + "winRate": 0.002003205128205128, + "closedCount": 2496, + "realized30d": 0, + "realizedTotal": 1604 + } + } +} \ No newline at end of file diff --git a/ui/public/llms.txt b/ui/public/llms.txt new file mode 100644 index 0000000..fed20a2 --- /dev/null +++ b/ui/public/llms.txt @@ -0,0 +1,18 @@ +# Auspex + +> A Polymarket-backed crypto-bet screener and trading surface. Live crypto prediction markets scored by distance-to-trigger and resolution confidence, with non-custodial trading, wallet tracking, social follows, and distance-to-trigger alerts. + +## Pages + +- [Screener](https://auspex.to/): the home screener — live crypto prediction markets sorted by signal +- [Docs](https://auspex.to/docs): how Auspex works +- [Leaderboard](https://auspex.to/leaderboard): top Polymarket traders by performance +- [Security](https://auspex.to/security): the non-custodial security model +- [Changelog](https://auspex.to/changelog): release notes +- [Privacy](https://auspex.to/privacy) · [Terms](https://auspex.to/terms) + +## About + +- Non-custodial: your wallet signs every trade; Auspex never holds funds or the keys that authorize a transfer. +- Data: the Polymarket CLOB for live order books and a crypto-event snapshot refreshed roughly every 15 minutes. +- Not affiliated with Polymarket. Informational only; not trading or investment advice. diff --git a/ui/scripts/build-leaderboard-stats.ts b/ui/scripts/build-leaderboard-stats.ts new file mode 100644 index 0000000..9ca4bc6 --- /dev/null +++ b/ui/scripts/build-leaderboard-stats.ts @@ -0,0 +1,142 @@ +/** + * Precompute per-wallet stats for the leaderboard → ui/public/leaderboard-stats.json. + * + * Runs in CI (.github/workflows/leaderboard-stats.yml, every 6h). The public + * leaderboard API only gives profit/volume per wallet; win-rate + sample size + * need each wallet's full /trades history — a ~80-wallet fan-out that does NOT + * belong in a per-render client fetch, so we batch it here on a schedule and + * serve the result as a static file (redeployed on each commit, so no staleness). + * + * The FIFO realized-P&L math is the canonical computeWalletPnl from + * lib/walletPnl.ts — imported, not reimplemented, so there's no drift between + * this and the per-wallet scorecard on the wallet page. + * + * Run locally: npx tsx scripts/build-leaderboard-stats.ts (from ui/) + */ +import { readFile, writeFile } from "node:fs/promises"; +import { computeWalletPnl, type Trade } from "../lib/walletPnl"; + +const LB = "https://lb-api.polymarket.com"; +const DATA = "https://data-api.polymarket.com"; +const UA = "auspex-leaderboard-stats/1.0 (+https://auspex.to)"; +const PAGE_LIMIT = 500; +const MAX_PAGES = 5; // ≤2500 trades/wallet — matches lib/useWalletTrades +const BATCH = 6; // wallet fan-out concurrency — polite to the data API +const ADDR_RE = /^0x[0-9a-fA-F]{40}$/; + +type WalletStat = { + winRate: number | null; + closedCount: number; + realized30d: number; + realizedTotal: number; +}; + +async function getJson(url: string): Promise { + const r = await fetch(url, { headers: { "user-agent": UA } }); + if (!r.ok) throw new Error(`HTTP ${r.status} for ${url}`); + return r.json(); +} + +/** Union of the top-profit and top-volume leaderboards (deduped, valid addrs). */ +async function leaderboardWallets(): Promise { + const [profit, volume] = await Promise.all([ + getJson(`${LB}/profit`), + getJson(`${LB}/volume`), + ]); + const out: string[] = []; + const seen = new Set(); + for (const list of [profit, volume]) { + if (!Array.isArray(list)) continue; + for (const e of list) { + const w = e && typeof e === "object" ? (e as { proxyWallet?: unknown }).proxyWallet : null; + if (typeof w === "string" && ADDR_RE.test(w) && !seen.has(w)) { + seen.add(w); + out.push(w); + } + } + } + return out; +} + +async function fetchTrades(wallet: string): Promise { + const acc: Trade[] = []; + for (let page = 0; page < MAX_PAGES; page++) { + const json = await getJson( + `${DATA}/trades?user=${wallet}&limit=${PAGE_LIMIT}&offset=${page * PAGE_LIMIT}`, + ); + if (!Array.isArray(json) || json.length === 0) break; + for (const t of json) { + if (!t || typeof t !== "object") continue; + const r = t as Record; + acc.push({ + proxyWallet: String(r.proxyWallet ?? wallet), + side: r.side === "SELL" ? "SELL" : "BUY", + asset: String(r.asset ?? ""), + conditionId: String(r.conditionId ?? ""), + size: Number(r.size), + price: Number(r.price), + timestamp: Number(r.timestamp), + }); + } + if (json.length < PAGE_LIMIT) break; + } + return acc; +} + +function statFor(trades: Trade[]): WalletStat { + const pnl = computeWalletPnl(trades, []); // no positions → realized + counts only + return { + winRate: pnl.closedCount > 0 ? pnl.wonCount / pnl.closedCount : null, + closedCount: pnl.closedCount, + realized30d: Math.round(pnl.realized30d), + realizedTotal: Math.round(pnl.realizedTotal), + }; +} + +async function main() { + const wallets = await leaderboardWallets(); + console.log(`Computing stats for ${wallets.length} leaderboard wallets…`); + const stats: Record = {}; + + for (let i = 0; i < wallets.length; i += BATCH) { + const batch = wallets.slice(i, i + BATCH); + const results = await Promise.allSettled( + batch.map(async (w) => [w, statFor(await fetchTrades(w))] as const), + ); + for (const res of results) { + if (res.status === "fulfilled") stats[res.value[0]] = res.value[1]; + // a wallet that errors is simply omitted — the card falls back to + // profit/volume-only, never a broken render + } + console.log(` …${Math.min(i + BATCH, wallets.length)}/${wallets.length}`); + } + + const dest = new URL("../public/leaderboard-stats.json", import.meta.url); + + // Skip the write (→ no commit, no redeploy) when the stats are byte-identical + // to what's already published. `generatedAt` is excluded from the compare so + // a quiet 6h window doesn't churn a no-op commit every run. + const serializedStats = JSON.stringify(stats); + try { + const prev = JSON.parse(await readFile(dest, "utf-8")) as { stats?: unknown }; + if (JSON.stringify(prev.stats ?? {}) === serializedStats) { + console.log("Stats unchanged — leaving the file as-is."); + return; + } + } catch { + // no existing file (first run) → fall through and write + } + + const out = { + generatedAt: new Date().toISOString(), + count: Object.keys(stats).length, + stats, + }; + await writeFile(dest, JSON.stringify(out, null, 2)); + console.log(`Wrote ${out.count} wallet stats → public/leaderboard-stats.json`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +});