feat: implement Polymarket read-only data service and add scan terminal dashboard components

This commit is contained in:
2569718930@qq.com
2026-04-24 10:19:16 +08:00
parent c05cdd17c6
commit c61a3f500d
9 changed files with 707 additions and 419 deletions
@@ -9251,35 +9251,25 @@
border-bottom: 1px solid rgba(118, 146, 188, 0.1);
}
.root :global(.scan-topbar-tabs) {
.root :global(.scan-topbar-title) {
display: flex;
gap: 28px;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.root :global(.scan-topbar-tab) {
position: relative;
border: none;
background: transparent;
color: #b4c8e2;
font-size: 17px;
font-weight: 700;
cursor: pointer;
.root :global(.scan-topbar-title strong) {
margin: 0;
font-size: 28px;
line-height: 1.08;
letter-spacing: -0.04em;
color: #f3f8ff;
}
.root :global(.scan-topbar-tab.active) {
color: #fff;
}
.root :global(.scan-topbar-tab.active::after) {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -18px;
height: 3px;
border-radius: 999px;
background: #3f8cff;
box-shadow: 0 0 14px rgba(63, 140, 255, 0.4);
.root :global(.scan-topbar-title span) {
color: #8fa4c3;
font-size: 14px;
line-height: 1.5;
}
.root :global(.scan-topbar-actions) {
@@ -9329,8 +9319,6 @@
.root :global(.scan-ghost-button),
.root :global(.scan-cta-ghost),
.root :global(.scan-theme-button),
.root :global(.scan-sort-pill),
.root :global(.scan-icon-pill),
.root :global(.scan-detail-action-button),
.root :global(.scan-detail-icon-button),
.root :global(.scan-view-all-button) {
@@ -9386,21 +9374,8 @@
animation: spin 1s linear infinite;
}
.root :global(.scan-hero h1) {
margin: 0;
font-size: 44px;
line-height: 1.08;
letter-spacing: -0.04em;
}
.root :global(.scan-hero p) {
margin: 10px 0 0;
color: #8fa4c3;
font-size: 16px;
}
.root :global(.scan-kpi-bar) {
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
@@ -9425,6 +9400,10 @@
color: #17d98b;
}
.root :global(.scan-kpi-card.cyan .scan-kpi-label) {
color: #37d6ff;
}
.root :global(.scan-kpi-card.purple .scan-kpi-label) {
color: #a35cff;
}
@@ -9433,6 +9412,14 @@
color: #49a3ff;
}
.root :global(.scan-kpi-card.red .scan-kpi-label) {
color: #ff647c;
}
.root :global(.scan-kpi-card.amber .scan-kpi-label) {
color: #ffb84f;
}
.root :global(.scan-kpi-card.orange .scan-kpi-label) {
color: #ffb020;
}
@@ -9487,20 +9474,37 @@
color: #fff;
}
.root :global(.scan-list-controls) {
.root :global(.scan-list-status) {
display: flex;
gap: 10px;
align-items: center;
}
.root :global(.scan-sort-pill) {
min-width: 188px;
padding: 12px 14px;
.root :global(.scan-status-chip) {
display: inline-flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid rgba(98, 126, 167, 0.22);
background: rgba(10, 22, 38, 0.92);
color: #c4d4e9;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.02em;
}
.root :global(.scan-icon-pill) {
width: 48px;
display: grid;
place-items: center;
.root :global(.scan-status-chip.live) {
border-color: rgba(55, 214, 255, 0.28);
background: rgba(55, 214, 255, 0.1);
color: #71e6ff;
}
.root :global(.scan-status-chip.stale) {
border-color: rgba(255, 184, 79, 0.24);
background: rgba(255, 184, 79, 0.1);
color: #ffc765;
}
.root :global(.scan-table-shell) {
@@ -9518,6 +9522,27 @@
min-height: 340px;
}
.root :global(.scan-table-banner) {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid rgba(95, 124, 168, 0.12);
background: linear-gradient(180deg, rgba(255, 184, 79, 0.08), rgba(255, 184, 79, 0.03));
}
.root :global(.scan-table-banner strong) {
font-size: 13px;
font-weight: 800;
color: #ffd27f;
}
.root :global(.scan-table-banner span) {
font-size: 13px;
color: #c7d4e7;
}
.root :global(.scan-table-header) {
display: grid;
grid-template-columns: 46px minmax(180px, 1.08fr) minmax(132px, 0.72fr) minmax(220px, 1.08fr) minmax(150px, 0.82fr) 92px 82px;
@@ -153,8 +153,8 @@ function ProbabilityPreview({
});
}
const modelLabel = locale === "en-US" ? "M" : "模";
const marketLabel = locale === "en-US" ? "P" : "市";
const modelLabel = "EMOS";
const marketLabel = locale === "en-US" ? "Market" : "市";
return (
<div className="scan-distribution-preview">
@@ -202,28 +202,53 @@ function ScoreRing({ score }: { score?: number | null }) {
export const OpportunityTable = React.memo(function OpportunityTable({
rows,
status,
stale,
staleReason,
loading,
selectedRowId,
onSelectRow,
}: {
rows: ScanOpportunityRow[];
status?: string | null;
stale?: boolean;
staleReason?: string | null;
loading?: boolean;
selectedRowId?: string | null;
onSelectRow?: (row: ScanOpportunityRow) => void;
}) {
const { locale } = useI18n();
const isEn = locale === "en-US";
const hasRows = rows.length > 0;
if (!rows.length) {
if (!hasRows) {
const title =
loading
? isEn
? "Scanning markets"
: "正在扫描市场"
: status === "failed"
? isEn
? "Scan failed"
: "扫描失败"
: isEn
? "No tradable market right now"
: "当前暂无可交易市场";
const copy =
loading
? isEn
? "Waiting for the latest market snapshot. Existing data will stay on screen when available."
: "正在等待最新市场快照;如果有旧数据,会继续保留在页面上。"
: status === "failed"
? staleReason || (isEn ? "No valid market snapshot is available." : "当前没有可用的市场快照。")
: isEn
? "The current snapshot does not contain a tradable main signal."
: "当前快照里还没有可交易的主信号。";
return (
<div className="scan-table-shell empty">
<div className="scan-empty-state">
<div className="scan-empty-title">
{isEn ? "No main signal right now" : "当前无主信号"}
</div>
<div className="scan-empty-copy">
{isEn
? "No row passed the price, spread, liquidity, and edge thresholds."
: "当前没有机会同时满足价格、点差、流动性和 edge 过滤。"}
</div>
<div className="scan-empty-title">{title}</div>
<div className="scan-empty-copy">{copy}</div>
</div>
</div>
);
@@ -231,12 +256,18 @@ export const OpportunityTable = React.memo(function OpportunityTable({
return (
<div className="scan-table-shell">
{stale ? (
<div className="scan-table-banner">
<strong>{isEn ? "Showing delayed snapshot" : "当前显示延迟快照"}</strong>
<span>{staleReason || (isEn ? "Latest refresh failed, fallback to the last successful scan." : "最新刷新失败,已回退到上次成功扫描结果。")}</span>
</div>
) : null}
<div className="scan-table-header">
<span />
<span>{isEn ? "City / Market" : "城市 / 市场"}</span>
<span>{isEn ? "Local Time / Phase" : "当前时间 / 阶段"}</span>
<span>{isEn ? "EMOS vs Market" : "EMOS 分布 vs 市场分布"}</span>
<span>{isEn ? "Best Opportunity" : "最佳机会"}</span>
<span>{isEn ? "EMOS / Market" : "EMOS / 市场"}</span>
<span>{isEn ? "Quote / Model" : "买价 / 模型"}</span>
<span>{isEn ? "Edge" : "边际优势"}</span>
<span>{isEn ? "Score" : "综合得分"}</span>
</div>
@@ -292,8 +323,8 @@ export const OpportunityTable = React.memo(function OpportunityTable({
{formatAction(row, locale, tempSymbol)}
</div>
<div className="scan-trade-sub">
{formatPercent(row.ask != null ? row.ask * 100 : null)} {" "}
{formatPercent(row.model_probability != null ? row.model_probability * 100 : null)}
{isEn ? "Buy" : "买价"} {row.ask != null ? `${Math.round(row.ask * 100)}¢` : "--"} ·{" "}
EMOS {formatPercent(row.model_probability != null ? row.model_probability * 100 : null)}
</div>
<div className="scan-trade-note">
{normalizeTemperatureLabel(row.target_label, tempSymbol) ||
+78 -35
View File
@@ -2,56 +2,99 @@
import React from "react";
import { useI18n } from "@/hooks/useI18n";
import type { ScanTerminalResponse } from "@/lib/dashboard-types";
import type { ScanOpportunityRow, ScanTerminalResponse } from "@/lib/dashboard-types";
type KPIData = ScanTerminalResponse["summary"];
function formatVolume(value: number): string {
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `$${(value / 1_000).toFixed(0)}K`;
return `$${value.toFixed(0)}`;
function formatPercent(value?: number | null, signed = false): string {
if (value == null || Number.isNaN(Number(value))) return "--";
const numeric = Number(value);
return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
}
export function ScanKPIBar({ data }: { data: KPIData }) {
function countByRisk(rows: ScanOpportunityRow[]) {
return rows.reduce(
(acc, row) => {
const risk = String(row.risk_level || "").toLowerCase();
if (risk.includes("high")) acc.high += 1;
else if (risk.includes("medium")) acc.medium += 1;
else acc.low += 1;
return acc;
},
{ high: 0, medium: 0, low: 0 },
);
}
export function ScanKPIBar({
response,
rows,
totalCities,
loading,
}: {
response: ScanTerminalResponse | null;
rows: ScanOpportunityRow[];
totalCities: number;
loading: boolean;
}) {
const { locale } = useI18n();
const isEn = locale === "en-US";
const summary = response?.summary;
const riskCounts = countByRisk(rows);
const tradableRows = rows.filter((row) => row.tradable && !row.closed);
const liveRows = rows.filter((row) => row.active || row.accepting_orders);
const bestRow = rows[0] || null;
const statusLabel =
loading && !response
? isEn
? "Scanning"
: "扫描中"
: response?.status === "stale"
? isEn
? "Stale"
: "旧数据"
: response?.status === "failed"
? isEn
? "Failed"
: "失败"
: isEn
? "Live"
: "最新";
const cards = [
{
label: isEn ? "Recommended" : "推荐机会",
value: String(data.recommended_count),
note: `${isEn ? "Visible" : "当前展示"} ${data.visible_count}`,
tone: "green",
label: isEn ? "Scan Status" : "扫描状态",
value: `${rows.length}/${totalCities || 0}`,
note:
loading && response
? isEn
? "Refreshing latest snapshot"
: "正在刷新最新快照"
: response?.status === "stale"
? response.stale_reason || (isEn ? "Using last good snapshot" : "正在使用上次成功快照")
: response?.status === "failed"
? response.stale_reason || (isEn ? "No valid snapshot" : "当前没有可用快照")
: `${isEn ? "Snapshot" : "快照"} · ${statusLabel}`,
tone: response?.status === "failed" ? "red" : response?.status === "stale" ? "amber" : "cyan",
},
{
label: isEn ? "Avg Edge" : "平均边际优势",
value:
data.avg_edge_percent != null
? `+${data.avg_edge_percent.toFixed(1)}%`
: "--",
note: `${isEn ? "Candidates" : "候选总数"} ${data.candidate_total}`,
tone: "purple",
},
{
label: isEn ? "Avg Confidence" : "平均主信号置信度",
value:
data.avg_primary_confidence != null
? `+${data.avg_primary_confidence.toFixed(1)}%`
: "--",
note: isEn ? "Main signal score" : "主信号评分",
label: isEn ? "Book Status" : "盘口状态",
value: `${liveRows.length}`,
note: `${isEn ? "Tradable" : "可交易"} ${tradableRows.length} · ${isEn ? "No edge" : "无机会"} ${Math.max(0, rows.length - tradableRows.length)}`,
tone: "blue",
},
{
label: isEn ? "Tradable Markets" : "可交易市场",
value: String(data.tradable_market_count),
note: `${isEn ? "Filtered" : "过滤后"} / ${data.candidate_total || 0}`,
tone: "orange",
label: isEn ? "Opportunity Quality" : "机会质量",
value: formatPercent(summary?.avg_edge_percent, true),
note: bestRow
? `${isEn ? "Best" : "最佳"} ${bestRow.city_display_name || bestRow.display_name || bestRow.city} · ${formatPercent(bestRow.edge_percent, true)}`
: isEn
? "No active market now"
: "当前没有活跃市场",
tone: "green",
},
{
label: isEn ? "Total Volume" : "总成交量",
value: formatVolume(data.total_volume),
note: isEn ? "Past 24 hours" : "过去 24 小时",
tone: "neutral",
label: isEn ? "Risk Layers" : "风险层",
value: `${riskCounts.high} / ${riskCounts.medium} / ${riskCounts.low}`,
note: `${isEn ? "High / Med / Low" : "高 / 中 / 低"}`,
tone: "purple",
},
];
@@ -3,8 +3,6 @@
import clsx from "clsx";
import Link from "next/link";
import {
Bell,
Menu,
RefreshCw,
Moon,
Sun,
@@ -64,26 +62,8 @@ const DEFAULT_FILTERS: FilterState = {
limit: 28,
};
const NAV_ITEMS = [
{ zh: "扫描台", en: "Terminal" },
{ zh: "市场", en: "Markets" },
{ zh: "分析", en: "Analysis" },
{ zh: "组合", en: "Portfolio" },
{ zh: "监控", en: "Monitor" },
{ zh: "设置", en: "Settings" },
];
type TopSection = "terminal" | "markets" | "analysis" | "portfolio" | "monitor" | "settings";
type ContentView = "list" | "map" | "calendar";
type ThemeMode = "dark" | "light";
const TOP_SECTION_ORDER: TopSection[] = [
"terminal",
"markets",
"analysis",
"portfolio",
"monitor",
"settings",
];
function formatPercent(value?: number | null, signed = false) {
if (value == null || Number.isNaN(Number(value))) return "--";
@@ -379,9 +359,6 @@ function DetailPanel({
{loading ? ` · ${isEn ? "loading" : "载入中"}` : ""}
</div>
</div>
<button type="button" className="scan-detail-action-button">
{isEn ? "Add Watch" : "添加自选"}
</button>
</div>
<div className="scan-detail-primary-actions">
@@ -443,18 +420,9 @@ function DetailPanel({
</strong>
</div>
<div className="scan-kv">
<span>{isEn ? "Window Left" : "剩余有效时间"}</span>
<span>{isEn ? "Time To Peak" : "距离预测峰值"}</span>
<strong>{formatRemainingWindow(displayRow.remaining_window_minutes, locale)}</strong>
</div>
<div className="scan-kv">
<span>{isEn ? "Bias" : "分布偏移"}</span>
<strong>
{displayRow.distribution_bias_direction || "--"} ·{" "}
{displayRow.distribution_bias_score != null
? displayRow.distribution_bias_score.toFixed(0)
: "--"}
</strong>
</div>
<div className="scan-kv">
<span>{isEn ? "Airport" : "机场锚点"}</span>
<strong>{localizedAirport || "--"}</strong>
@@ -521,38 +489,35 @@ function DetailPanel({
</section>
<section className="scan-detail-section">
<div className="scan-trade-cards">
<div className="scan-trade-card buy">
<div className="scan-trade-card-title">
{isEn ? "Buy Yes" : "买入 Yes"}{" "}
{normalizeTemperatureLabel(displayRow.target_label, tempSymbol) || ""}
</div>
<p>
{formatPrice(getDetailSideAsk(yesRow, marketScan, "yes"))} {" "}
{formatProbability(yesRow?.model_probability)}
</p>
<p className="positive">{formatPercent(yesRow?.edge_percent, true)} {isEn ? "edge" : "边际优势"}</p>
<p>
{isEn ? "Bid / Ask" : "买卖价"} {formatPrice(getDetailSideBid(yesRow, marketScan, "yes"))} /{" "}
{formatPrice(getDetailSideAsk(yesRow, marketScan, "yes"))}
</p>
<div className="scan-detail-section-title">
{isEn ? "Main Signal" : "主信号概况"}
</div>
<div className="scan-kv-list compact">
<div className="scan-kv">
<span>{isEn ? "Best Side" : "主方向"}</span>
<strong>{displayRow.side === "no" ? "NO" : "YES"}</strong>
</div>
<div className="scan-trade-card sell">
<div className="scan-trade-card-title">
{isEn ? "Buy No" : "买入 No"}{" "}
{normalizeTemperatureLabel(displayRow.target_label, tempSymbol) || ""}
</div>
<p>
{formatPrice(getDetailSideAsk(noRow, marketScan, "no"))} {" "}
{formatProbability(noRow?.model_probability)}
</p>
<p className={Number(noRow?.edge_percent || 0) >= 0 ? "positive" : "negative"}>
{formatPercent(noRow?.edge_percent, true)} {isEn ? "edge" : "边际优势"}
</p>
<p>
{isEn ? "Bid / Ask" : "买卖价"} {formatPrice(getDetailSideBid(noRow, marketScan, "no"))} /{" "}
{formatPrice(getDetailSideAsk(noRow, marketScan, "no"))}
</p>
<div className="scan-kv">
<span>{isEn ? "Best Buy" : "最优买价"}</span>
<strong>
{displayRow.side === "no"
? formatPrice(getDetailSideAsk(noRow, marketScan, "no"))
: formatPrice(getDetailSideAsk(yesRow, marketScan, "yes"))}
</strong>
</div>
<div className="scan-kv">
<span>{isEn ? "EMOS" : "EMOS 概率"}</span>
<strong>
{displayRow.side === "no"
? formatProbability(noRow?.model_probability)
: formatProbability(yesRow?.model_probability)}
</strong>
</div>
<div className="scan-kv">
<span>{isEn ? "Edge" : "边际优势"}</span>
<strong className={Number(displayRow.edge_percent || 0) >= 0 ? "positive" : "negative"}>
{formatPercent(displayRow.edge_percent, true)}
</strong>
</div>
</div>
</section>
@@ -630,6 +595,11 @@ function CalendarView({
className={`scan-calendar-card ${selectedRowId === row.id ? "selected" : ""}`}
onClick={() => onSelectRow(row)}
>
{(() => {
const tempSymbol = row.temp_symbol || "°C";
const phaseMeta = getWindowPhaseMeta(row, locale);
return (
<>
<div className="scan-calendar-city">
{getLocalizedCityName(
row.city,
@@ -637,13 +607,19 @@ function CalendarView({
locale,
)}
</div>
<div className={`scan-calendar-action ${row.side === "no" ? "sell" : "buy"}`}>
{row.action || row.target_label || "--"}
<div className="scan-calendar-action">
{locale === "en-US" ? "DEB high" : "DEB 预测高点"} ·{" "}
{row.deb_prediction != null
? formatTemperatureValue(row.deb_prediction, tempSymbol)
: "--"}
</div>
<div className="scan-calendar-meta">
<span>{row.local_time || "--"}</span>
<span>{formatPercent(row.edge_percent, true)}</span>
<span>{phaseMeta.label}</span>
</div>
</>
);
})()}
</button>
))}
</div>
@@ -670,97 +646,6 @@ function OverviewMapView({ locale }: { locale: string }) {
);
}
function PortfolioView({
rows,
locale,
}: {
rows: ScanOpportunityRow[];
locale: string;
}) {
const yesCount = rows.filter((row) => row.side === "yes").length;
const noCount = rows.filter((row) => row.side === "no").length;
const avgScore =
rows.length > 0
? rows.reduce((sum, row) => sum + Number(row.final_score || 0), 0) / rows.length
: null;
return (
<div className="scan-summary-grid">
<div className="scan-summary-card">
<div className="scan-summary-label">{locale === "en-US" ? "YES Ideas" : "YES 机会"}</div>
<div className="scan-summary-value">{yesCount}</div>
</div>
<div className="scan-summary-card">
<div className="scan-summary-label">{locale === "en-US" ? "NO Ideas" : "NO 机会"}</div>
<div className="scan-summary-value">{noCount}</div>
</div>
<div className="scan-summary-card wide">
<div className="scan-summary-label">{locale === "en-US" ? "Avg Score" : "平均评分"}</div>
<div className="scan-summary-value">
{avgScore != null ? `${avgScore.toFixed(0)}/100` : "--"}
</div>
</div>
</div>
);
}
function MonitorView({
rows,
locale,
}: {
rows: ScanOpportunityRow[];
locale: string;
}) {
const groups = useMemo(() => {
const result = new Map<string, number>();
rows.forEach((row) => {
const label = getWindowPhaseMeta(row, locale).label;
result.set(label, (result.get(label) || 0) + 1);
});
return Array.from(result.entries());
}, [rows, locale]);
return (
<div className="scan-summary-grid monitor">
{groups.map(([label, count]) => (
<div key={label} className="scan-summary-card">
<div className="scan-summary-label">{label}</div>
<div className="scan-summary-value">{count}</div>
</div>
))}
</div>
);
}
function SettingsView({ locale }: { locale: string }) {
return (
<div className="scan-settings-view">
<div className="scan-settings-card">
<div className="scan-summary-label">{locale === "en-US" ? "Market Discovery" : "市场发现"}</div>
<div className="scan-settings-copy">
{locale === "en-US" ? "Gamma REST refreshes every 60s." : "Gamma REST 每 60 秒刷新一次。"}
</div>
</div>
<div className="scan-settings-card">
<div className="scan-summary-label">{locale === "en-US" ? "Price Layer" : "价格层"}</div>
<div className="scan-settings-copy">
{locale === "en-US"
? "CLOB REST prices and books refresh every 30s."
: "CLOB REST 价格和盘口每 30 秒刷新一次。"}
</div>
</div>
<div className="scan-settings-card">
<div className="scan-summary-label">{locale === "en-US" ? "Server Profile" : "服务器配置"}</div>
<div className="scan-settings-copy">
{locale === "en-US"
? "2GB profile: one polling loop, cached REST, no websocket fan-out."
: "2G 配置:单轮询、REST 缓存优先、不做 websocket 扇出。"}
</div>
</div>
</div>
);
}
function ScanTerminalScreen() {
const store = useDashboardStore();
const { locale, toggleLocale } = useI18n();
@@ -776,7 +661,6 @@ function ScanTerminalScreen() {
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
const [detailByRowId, setDetailByRowId] = useState<Record<string, MarketScan | null>>({});
const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null);
const [activeSection, setActiveSection] = useState<TopSection>("terminal");
const [activeView, setActiveView] = useState<ContentView>("list");
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
const [userLocalTime, setUserLocalTime] = useState("--");
@@ -806,12 +690,12 @@ function ScanTerminalScreen() {
const fetchTerminal = async (filters: FilterState, force = false) => {
setLoading(true);
setError(null);
try {
const response = await dashboardClient.getScanTerminal(filters, { force });
startTransition(() => {
setTerminalData(response);
setActiveFilters(filters);
setError(response.status === "failed" ? response.stale_reason || null : null);
setSelectedRowId((current) => {
if (current && response.rows.some((row) => row.id === current)) {
return current;
@@ -827,7 +711,7 @@ function ScanTerminalScreen() {
};
const fetchDetail = async (row: ScanOpportunityRow) => {
if (!row.market_slug || !row.selected_date) return;
if (!row.market_slug || !row.selected_date || row.closed) return;
if (detailByRowId[row.id] !== undefined) return;
setDetailLoadingId(row.id);
try {
@@ -880,15 +764,13 @@ function ScanTerminalScreen() {
window.localStorage.setItem("polyweather_scan_theme", themeMode);
}, [themeMode]);
const resolvedView: ContentView = activeView;
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
const resolvedView: ContentView =
activeSection === "markets"
? "map"
: activeSection === "analysis"
? "calendar"
: activeView;
const activeDetailRow = resolvedView === "map" && mapFocusedCity ? mapFocusedRow : selectedRow;
const selectedDetail = activeDetailRow ? detailByRowId[activeDetailRow.id] : null;
const scanStatus = terminalData?.status || (loading ? "loading" : error ? "failed" : "ready");
const staleReason =
terminalData?.stale_reason || error || null;
useEffect(() => {
if (!activeDetailRow) return;
@@ -914,15 +796,6 @@ function ScanTerminalScreen() {
}, []);
const renderMainView = () => {
if (activeSection === "portfolio") {
return <PortfolioView rows={timeSortedRows} locale={locale} />;
}
if (activeSection === "monitor") {
return <MonitorView rows={timeSortedRows} locale={locale} />;
}
if (activeSection === "settings") {
return <SettingsView locale={locale} />;
}
if (resolvedView === "map") {
return (
<div className="scan-map-view">
@@ -951,6 +824,10 @@ function ScanTerminalScreen() {
<>
<OpportunityTable
rows={timeSortedRows}
status={scanStatus}
stale={Boolean(terminalData?.stale)}
staleReason={staleReason}
loading={loading}
selectedRowId={selectedRowId}
onSelectRow={handleSelectRow}
/>
@@ -973,19 +850,21 @@ function ScanTerminalScreen() {
<main className="scan-data-grid">
<div className="scan-topbar">
<div className="scan-topbar-tabs">
{NAV_ITEMS.map((item, index) => (
<button
key={item.zh}
type="button"
className={`scan-topbar-tab ${
TOP_SECTION_ORDER[index] === activeSection ? "active" : ""
}`}
onClick={() => setActiveSection(TOP_SECTION_ORDER[index])}
>
{isEn ? item.en : item.zh}
</button>
))}
<div className="scan-topbar-title">
<strong>{isEn ? "Market Scan Terminal" : "市场扫描台"}</strong>
<span>
{loading
? isEn
? "Refreshing current market snapshot"
: "正在刷新当前市场快照"
: terminalData?.stale
? isEn
? "Showing the last successful snapshot"
: "当前显示上次成功快照"
: isEn
? "Read-only market scan with peak-first main signal"
: "只读市场扫描,主信号按 EMOS 主峰优先"}
</span>
</div>
<div className="scan-topbar-actions">
<button
@@ -1012,11 +891,7 @@ function ScanTerminalScreen() {
</button>
<button type="button" className="scan-ghost-button" onClick={() => void fetchTerminal(activeFilters, true)}>
<RefreshCw size={14} className={loading ? "spin" : undefined} />
{isEn ? "Refresh" : "筛选"}
</button>
<button type="button" className="scan-cta-ghost">
<Bell size={14} />
{isEn ? "Custom Alerts" : "自定义提醒"}
{isEn ? "Refresh" : "刷新"}
</button>
<Link
href={accountHref}
@@ -1029,28 +904,11 @@ function ScanTerminalScreen() {
</div>
</div>
<section className="scan-hero">
<h1>{isEn ? "Tradable Opportunities" : "可交易机会"}</h1>
<p>
{isEn
? "Use EMOS distribution, live order book, and timing windows to isolate the one actionable signal."
: "基于当前时间、实况数据和模型预测,筛选出最具交易价值的市场。"}
</p>
</section>
<ScanKPIBar
data={
terminalData?.summary || {
recommended_count: 0,
visible_count: 0,
candidate_total: 0,
avg_edge_percent: null,
avg_primary_confidence: null,
tradable_market_count: 0,
total_volume: 0,
resolved_market_type: "maxtemp",
}
}
response={terminalData}
rows={timeSortedRows}
totalCities={store.cities.length}
loading={loading}
/>
<section className="scan-list-section">
@@ -1058,9 +916,8 @@ function ScanTerminalScreen() {
<div className="scan-list-tabs">
<button
type="button"
className={activeSection === "terminal" && resolvedView === "list" ? "active" : ""}
className={resolvedView === "list" ? "active" : ""}
onClick={() => {
setActiveSection("terminal");
setActiveView("list");
}}
>
@@ -1070,7 +927,6 @@ function ScanTerminalScreen() {
type="button"
className={resolvedView === "map" ? "active" : ""}
onClick={() => {
setActiveSection("terminal");
setActiveView("map");
}}
>
@@ -1080,39 +936,32 @@ function ScanTerminalScreen() {
type="button"
className={resolvedView === "calendar" ? "active" : ""}
onClick={() => {
setActiveSection("terminal");
setActiveView("calendar");
}}
>
{isEn ? "Calendar View" : "日历视图"}
</button>
</div>
<div className="scan-list-controls">
<div className="scan-sort-pill">
{isEn
? resolvedView === "calendar"
? "Sort: Date"
: resolvedView === "map"
? "Sort: City Map"
: "Sort: Your Time"
: resolvedView === "calendar"
? "排序:日期"
: resolvedView === "map"
? "排序:地图"
: "排序:用户时区时间"}
</div>
<button type="button" className="scan-icon-pill" aria-label="menu">
<Menu size={16} />
</button>
<div className="scan-list-status">
{terminalData?.stale ? (
<span className="scan-status-chip stale">
{isEn ? "Delayed snapshot" : "延迟快照"}
</span>
) : null}
{loading ? (
<span className="scan-status-chip live">
{isEn ? "Refreshing" : "刷新中"}
</span>
) : null}
</div>
</div>
{error ? (
{scanStatus === "failed" && !terminalData ? (
<div className="scan-empty-state">
<div className="scan-empty-title">
{isEn ? "Scan failed" : "扫描失败"}
</div>
<div className="scan-empty-copy">{error}</div>
<div className="scan-empty-copy">{staleReason}</div>
</div>
) : (
renderMainView()
+11
View File
@@ -497,6 +497,11 @@ export interface ScanOpportunityRow {
distribution_bias_direction?: string | null;
distribution_bias_score?: number | null;
distribution_bias_available?: boolean;
peak_probability?: number | null;
peak_value?: number | null;
peak_distance?: number | null;
peak_alignment_score?: number | null;
is_peak_candidate?: boolean;
window_phase?: string | null;
window_score?: number | null;
remaining_window_minutes?: number | null;
@@ -525,6 +530,12 @@ export interface PrimarySignal extends ScanOpportunityRow {}
export interface ScanTerminalResponse {
generated_at: string;
snapshot_id?: string | null;
status?: "ready" | "stale" | "failed" | string;
stale?: boolean;
stale_reason?: string | null;
last_success_at?: string | null;
last_failed_at?: string | null;
filters: ScanTerminalFilters;
summary: {
recommended_count: number;