diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 3e93bbe8..767e3669 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -694,6 +694,9 @@ function RegionalWhaleWatch({ isEn: boolean; rows: ScanOpportunityRow[]; }) { + type HolderInfo = { city: string; holders: Array<{ proxyWallet?: string; amount?: number; outcomeIndex?: number; pseudonym?: string; name?: string }> | null; loading: boolean }; + const [holderMap, setHolderMap] = useState>({}); + const regions = ["americas", "europe", "asia_pacific", "middle_east_africa"]; const regionLabels: Record = { americas: isEn ? "Americas" : "美洲", @@ -701,13 +704,31 @@ function RegionalWhaleWatch({ asia_pacific: isEn ? "Asia-Pacific" : "亚太", middle_east_africa: isEn ? "ME & Africa" : "中东非洲", }; - const topByRegion = regions.map((region) => { - const regionRows = rows - .filter((row) => row.trading_region === region) - .sort((a, b) => (Number(b.volume || b.book_liquidity || 0)) - (Number(a.volume || a.book_liquidity || 0))) - .slice(0, 3); - return { region, label: regionLabels[region] || region, rows: regionRows }; - }).filter((r) => r.rows.length > 0); + const topByRegion = useMemo(() => { + return regions.map((region) => { + const regionRows = rows + .filter((row) => row.trading_region === region) + .sort((a, b) => (Number(b.volume || b.book_liquidity || 0)) - (Number(a.volume || a.book_liquidity || 0))) + .slice(0, 2); + return { region, label: regionLabels[region] || region, rows: regionRows }; + }).filter((r) => r.rows.length > 0); + }, [rows, isEn]); + + useEffect(() => { + topByRegion.forEach(({ rows: regionRows }) => { + regionRows.forEach((row) => { + const city = String(row.city || "").toLowerCase(); + if (!city || holderMap[city]?.loading || holderMap[city]?.holders) return; + setHolderMap((prev) => ({ ...prev, [city]: { city, holders: null, loading: true } })); + fetch(`/api/city/${encodeURIComponent(city)}/holders?limit=6`, { cache: "no-store", headers: { Accept: "application/json" } }) + .then(async (res) => { + const json = await res.json() as { holders?: Array<{ proxyWallet?: string; amount?: number; outcomeIndex?: number; pseudonym?: string; name?: string }>; available?: boolean }; + setHolderMap((prev) => ({ ...prev, [city]: { city, holders: json.holders || [], loading: false } })); + }) + .catch(() => setHolderMap((prev) => ({ ...prev, [city]: { city, holders: null, loading: false } }))); + }); + }); + }, [topByRegion]); if (!topByRegion.length) return null; @@ -715,21 +736,42 @@ function RegionalWhaleWatch({
{topByRegion.map(({ region, label, rows: regionRows }) => ( -
+
{label}
{regionRows.map((row) => { + const city = String(row.city || "").toLowerCase(); + const info = holderMap[city]; const vol = Number(row.volume || row.book_liquidity || 0); return ( -
- - {row.city_display_name || row.city} - - - {row.target_label || row.market_question?.slice(0, 30) || "--"} - - - {vol >= 1000 ? `$${(vol / 1000).toFixed(1)}K` : `$${Math.round(vol)}`} - +
+
+ + {row.city_display_name || row.city} + + + {row.target_label || "--"} + + + {vol >= 1000 ? `$${(vol / 1000).toFixed(1)}K` : `$${Math.round(vol)}`} + +
+ {info?.loading ? ( +
{isEn ? "Loading holders..." : "加载持仓..."}
+ ) : info?.holders?.length ? ( + info.holders.slice(0, 3).map((h, i) => ( +
+ + {h.name || h.pseudonym || (h.proxyWallet ? `${String(h.proxyWallet).slice(0, 6)}...${String(h.proxyWallet).slice(-4)}` : "--")} + + + {h.outcomeIndex === 0 ? "YES" : h.outcomeIndex === 1 ? "NO" : ""}{" "} + {h.amount != null ? `${Number(h.amount).toFixed(0)}` : ""} + +
+ )) + ) : info ? ( +
{isEn ? "No holder data" : "无持仓数据"}
+ ) : null}
); })} diff --git a/src/data_collection/polymarket_readonly.py b/src/data_collection/polymarket_readonly.py index e872edab..fe50eae2 100644 --- a/src/data_collection/polymarket_readonly.py +++ b/src/data_collection/polymarket_readonly.py @@ -416,6 +416,11 @@ class PolymarketReadOnlyLayer: .strip() .rstrip("/") ) + self.data_url = ( + str(os.getenv("POLYMARKET_DATA_URL", "https://data-api.polymarket.com")) + .strip() + .rstrip("/") + ) self.http_timeout = _safe_float(os.getenv("POLYMARKET_HTTP_TIMEOUT_SEC")) or 20.0 self.market_cache_ttl = _safe_int( os.getenv("POLYMARKET_MARKET_CACHE_TTL_SEC", "60"), @@ -2388,6 +2393,35 @@ class PolymarketReadOnlyLayer: out.append(market) return out + def get_market_holders( + self, condition_id: str, limit: int = 10 + ) -> List[Dict[str, Any]]: + """Fetch top token holders for a market from the Polymarket Data API. + + Endpoint: GET /holders?market={conditionId}&limit={limit} + Returns a list of holder objects with proxyWallet, amount, outcomeIndex, + pseudonym, name, profileImage, etc. + """ + cid = str(condition_id or "").strip() + if not cid: + return [] + try: + resp = self._session.get( + f"{self.data_url}/holders", + params={"market": cid, "limit": limit}, + timeout=self.http_timeout, + ) + resp.raise_for_status() + payload = resp.json() + except Exception as exc: + logger.warning(f"Polymarket holders fetch failed (condition={cid[:20]}): {exc}") + return [] + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + return payload.get("holders") or payload.get("data") or [] + return [] + def resolve_city_clob_tokens(self, city_key: str) -> List[Dict[str, Any]]: """Resolve CLOB token IDs for a city using its local date.""" local_date = _city_local_date(city_key) diff --git a/web/routers/city.py b/web/routers/city.py index c70ef698..c497926a 100644 --- a/web/routers/city.py +++ b/web/routers/city.py @@ -170,3 +170,41 @@ async def city_market_scan( target_date=target_date, lite=lite, ) + + +@router.get("/api/city/{name}/holders") +async def city_holders( + name: str, + limit: int = 10, +): + """Return top token holders for a city's primary Polymarket market.""" + from web.services.city_payloads import _get_polymarket_layer + from web.analysis_service import _analyze + + layer = _get_polymarket_layer() + if not layer.enabled: + return {"holders": [], "available": False} + + data = _analyze(name, force_refresh=False, include_llm_commentary=False, detail_mode="market") + local_date = str(data.get("local_date") or "").strip() + if not local_date: + return {"holders": [], "available": False} + + scan = layer.build_market_scan( + city=name, + target_date=local_date, + include_related_buckets=False, + ) + if not scan.get("available"): + return {"holders": [], "available": False} + + condition_id = scan.get("selected_condition_id") + if not condition_id: + return {"holders": [], "available": False} + + holders = layer.get_market_holders(condition_id, limit=limit) + return { + "holders": holders, + "available": True, + "condition_id": condition_id, + }