"use client"; import clsx from "clsx"; import { useEffect, useMemo, useState } from "react"; import type { ScanOpportunityRow, ScanTerminalResponse } from "@/lib/dashboard-types"; import { resolveTradingRegionKey, TRADING_REGIONS, } from "@/components/dashboard/scan-terminal/continent-grouping"; import { Panel } from "@/components/dashboard/scan-terminal/Panel"; import { edgeClass, money, pct, rowName } from "@/components/dashboard/scan-terminal/utils"; type Holder = { amount?: number; name?: string; outcomeIndex?: number; proxyWallet?: string; pseudonym?: string; }; type HolderInfo = { holders: Holder[] | null; loading: boolean; }; const MARKET_OVERVIEW_REFRESH_MS = 10 * 60_000; function numeric(value: unknown) { return typeof value === "number" && Number.isFinite(value) ? value : 0; } function rowLiquidity(row: ScanOpportunityRow) { return numeric(row.book_liquidity || row.market_liquidity || row.volume); } function rowOpportunityScore(row: ScanOpportunityRow) { return ( numeric(row.final_score) * 0.5 + numeric(row.edge_percent ?? row.signed_gap ?? row.gap) * 2 + Math.log10(rowLiquidity(row) + 1) ); } function rowSpread(row: ScanOpportunityRow) { return numeric(row.spread); } function isStale(row: ScanOpportunityRow) { return Boolean(row.metar_context?.stale_for_today || row.metar_status?.stale_for_today); } function isWatch(row: ScanOpportunityRow) { const value = String(row.ai_decision || row.v4_metar_decision || row.signal_status || row.action || "").toLowerCase(); return value.includes("watch") || !row.tradable; } function holderLabel(holder: Holder) { return ( holder.name || holder.pseudonym || (holder.proxyWallet ? `${String(holder.proxyWallet).slice(0, 6)}...${String(holder.proxyWallet).slice(-4)}` : "--") ); } function CompactRowsTable({ empty, isEn, onSelectRow, rows, showSpread = false, }: { empty: string; isEn: boolean; onSelectRow: (row: ScanOpportunityRow) => void; rows: ScanOpportunityRow[]; showSpread?: boolean; }) { if (!rows.length) { return
{empty}
; } return ( {rows.map((row) => { const edge = numeric(row.edge_percent ?? row.signed_gap ?? row.gap); return ( onSelectRow(row)} className="cursor-pointer border-b border-slate-100 hover:bg-blue-50" > ); })}
{isEn ? "City / Contract" : "城市 / 合约"} {isEn ? "Model" : "模型"} {isEn ? "Market" : "市场"} {showSpread ? (isEn ? "Spread" : "价差") : "Edge"} {isEn ? "Liq" : "流动性"}
{rowName(row)}
{row.target_label || row.market_question || "--"}
{pct(row.model_probability ?? row.model_event_probability)} {pct(row.market_probability ?? row.market_event_probability)} {showSpread ? pct(row.spread) : pct(edge)} {money(rowLiquidity(row))}
); } export function MarketOverviewView({ isEn, onSelectRow, rows, }: { isEn: boolean; onSelectRow: (row: ScanOpportunityRow) => void; rows: ScanOpportunityRow[]; }) { const [overviewRows, setOverviewRows] = useState(rows); const [lastScanAt, setLastScanAt] = useState(null); useEffect(() => { setOverviewRows(rows); }, [rows]); useEffect(() => { let cancelled = false; let controller: AbortController | null = null; const refreshOverview = async () => { if (typeof fetch !== "function" || typeof AbortController === "undefined") return; controller?.abort(); controller = new AbortController(); try { const response = await fetch("/api/scan/terminal?force_refresh=false", { cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal, }); if (!response.ok) return; const payload = await response.json() as ScanTerminalResponse; if (!cancelled && Array.isArray(payload.rows)) { setOverviewRows(payload.rows); setLastScanAt(new Date()); } } catch (error) { if ((error as { name?: string })?.name !== "AbortError") { // Keep the existing snapshot; overview refresh should never blank the terminal. } } }; const intervalId = window.setInterval(() => { void refreshOverview(); }, MARKET_OVERVIEW_REFRESH_MS); return () => { cancelled = true; controller?.abort(); window.clearInterval(intervalId); }; }, []); const primaryRows = useMemo( () => overviewRows.filter((row) => row.is_primary_signal !== false), [overviewRows], ); const regionStats = useMemo( () => TRADING_REGIONS.map((region) => { const list = primaryRows.filter((row) => resolveTradingRegionKey(row) === region.key); const avgEdge = list.reduce((sum, row) => sum + numeric(row.edge_percent ?? row.signed_gap ?? row.gap), 0) / Math.max(list.length, 1); return { key: region.key, label: isEn ? region.labelEn : region.labelZh, contracts: list.length, tradable: list.filter((row) => row.tradable).length, heat: list.filter((row) => row.risk_level === "high" || numeric(row.current_temp) >= 30).length, avgEdge, liquidity: list.reduce((sum, row) => sum + rowLiquidity(row), 0), }; }).filter((item) => item.contracts > 0), [isEn, primaryRows], ); const topOpportunities = useMemo( () => [...primaryRows].sort((a, b) => rowOpportunityScore(b) - rowOpportunityScore(a)).slice(0, 12), [primaryRows], ); const riskRows = useMemo( () => primaryRows .filter((row) => isWatch(row) || numeric(row.edge_percent ?? row.signed_gap ?? row.gap) < 0 || row.closed || isStale(row)) .sort((a, b) => numeric(a.edge_percent ?? a.signed_gap ?? a.gap) - numeric(b.edge_percent ?? b.signed_gap ?? b.gap)) .slice(0, 12), [primaryRows], ); const liquidityRows = useMemo( () => [...primaryRows].sort((a, b) => rowLiquidity(b) - rowLiquidity(a)).slice(0, 8), [primaryRows], ); const tightSpreadRows = useMemo( () => [...primaryRows] .filter((row) => rowSpread(row) > 0) .sort((a, b) => rowSpread(a) - rowSpread(b)) .slice(0, 8), [primaryRows], ); const whaleRows = useMemo( () => liquidityRows.slice(0, 6), [liquidityRows], ); const [holderMap, setHolderMap] = useState>({}); useEffect(() => { whaleRows.forEach((row) => { const city = String(row.city || "").toLowerCase(); if (!city || holderMap[city]?.loading || holderMap[city]?.holders) return; setHolderMap((prev) => ({ ...prev, [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?: Holder[] }; setHolderMap((prev) => ({ ...prev, [city]: { holders: json.holders || [], loading: false } })); }) .catch(() => setHolderMap((prev) => ({ ...prev, [city]: { holders: null, loading: false } }))); }); }, [holderMap, whaleRows]); return (
{isEn ? "Scan: 10m" : "扫描:10分钟"} {lastScanAt ? ` · ${lastScanAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : ""} } >
{regionStats.map((item) => ( ))}
{whaleRows.map((row) => { const city = String(row.city || "").toLowerCase(); const info = holderMap[city]; return ( ); })}
); }