"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 (
| {isEn ? "City / Contract" : "城市 / 合约"} |
{isEn ? "Model" : "模型"} |
{isEn ? "Market" : "市场"} |
{showSpread ? (isEn ? "Spread" : "价差") : "Edge"} |
{isEn ? "Liq" : "流动性"} |
{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"
>
|
{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 (
);
})}
);
}