diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index a52d9001..675bdb0b 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -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; diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx index b3220684..beb69801 100644 --- a/frontend/components/dashboard/OpportunityTable.tsx +++ b/frontend/components/dashboard/OpportunityTable.tsx @@ -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 (
@@ -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 (
-
- {isEn ? "No main signal right now" : "当前无主信号"} -
-
- {isEn - ? "No row passed the price, spread, liquidity, and edge thresholds." - : "当前没有机会同时满足价格、点差、流动性和 edge 过滤。"} -
+
{title}
+
{copy}
); @@ -231,12 +256,18 @@ export const OpportunityTable = React.memo(function OpportunityTable({ return (
+ {stale ? ( +
+ {isEn ? "Showing delayed snapshot" : "当前显示延迟快照"} + {staleReason || (isEn ? "Latest refresh failed, fallback to the last successful scan." : "最新刷新失败,已回退到上次成功扫描结果。")} +
+ ) : null}
{isEn ? "City / Market" : "城市 / 市场"} {isEn ? "Local Time / Phase" : "当前时间 / 阶段"} - {isEn ? "EMOS vs Market" : "EMOS 分布 vs 市场分布"} - {isEn ? "Best Opportunity" : "最佳机会"} + {isEn ? "EMOS / Market" : "EMOS / 市场"} + {isEn ? "Quote / Model" : "买价 / 模型"} {isEn ? "Edge" : "边际优势"} {isEn ? "Score" : "综合得分"}
@@ -292,8 +323,8 @@ export const OpportunityTable = React.memo(function OpportunityTable({ {formatAction(row, locale, tempSymbol)}
- {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)}
{normalizeTemperatureLabel(row.target_label, tempSymbol) || diff --git a/frontend/components/dashboard/ScanKPIBar.tsx b/frontend/components/dashboard/ScanKPIBar.tsx index fbf7643e..23ed8ba1 100644 --- a/frontend/components/dashboard/ScanKPIBar.tsx +++ b/frontend/components/dashboard/ScanKPIBar.tsx @@ -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", }, ]; diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 3b9d3b15..00c364c6 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -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" : "载入中"}` : ""}
-
@@ -443,18 +420,9 @@ function DetailPanel({
- {isEn ? "Window Left" : "剩余有效时间"} + {isEn ? "Time To Peak" : "距离预测峰值"} {formatRemainingWindow(displayRow.remaining_window_minutes, locale)}
-
- {isEn ? "Bias" : "分布偏移"} - - {displayRow.distribution_bias_direction || "--"} ·{" "} - {displayRow.distribution_bias_score != null - ? displayRow.distribution_bias_score.toFixed(0) - : "--"} - -
{isEn ? "Airport" : "机场锚点"} {localizedAirport || "--"} @@ -521,38 +489,35 @@ function DetailPanel({
-
-
-
- {isEn ? "Buy Yes" : "买入 Yes"}{" "} - {normalizeTemperatureLabel(displayRow.target_label, tempSymbol) || ""} -
-

- {formatPrice(getDetailSideAsk(yesRow, marketScan, "yes"))} →{" "} - {formatProbability(yesRow?.model_probability)} -

-

{formatPercent(yesRow?.edge_percent, true)} {isEn ? "edge" : "边际优势"}

-

- {isEn ? "Bid / Ask" : "买卖价"} {formatPrice(getDetailSideBid(yesRow, marketScan, "yes"))} /{" "} - {formatPrice(getDetailSideAsk(yesRow, marketScan, "yes"))} -

+
+ {isEn ? "Main Signal" : "主信号概况"} +
+
+
+ {isEn ? "Best Side" : "主方向"} + {displayRow.side === "no" ? "NO" : "YES"}
-
-
- {isEn ? "Buy No" : "买入 No"}{" "} - {normalizeTemperatureLabel(displayRow.target_label, tempSymbol) || ""} -
-

- {formatPrice(getDetailSideAsk(noRow, marketScan, "no"))} →{" "} - {formatProbability(noRow?.model_probability)} -

-

= 0 ? "positive" : "negative"}> - {formatPercent(noRow?.edge_percent, true)} {isEn ? "edge" : "边际优势"} -

-

- {isEn ? "Bid / Ask" : "买卖价"} {formatPrice(getDetailSideBid(noRow, marketScan, "no"))} /{" "} - {formatPrice(getDetailSideAsk(noRow, marketScan, "no"))} -

+
+ {isEn ? "Best Buy" : "最优买价"} + + {displayRow.side === "no" + ? formatPrice(getDetailSideAsk(noRow, marketScan, "no")) + : formatPrice(getDetailSideAsk(yesRow, marketScan, "yes"))} + +
+
+ {isEn ? "EMOS" : "EMOS 概率"} + + {displayRow.side === "no" + ? formatProbability(noRow?.model_probability) + : formatProbability(yesRow?.model_probability)} + +
+
+ {isEn ? "Edge" : "边际优势"} + = 0 ? "positive" : "negative"}> + {formatPercent(displayRow.edge_percent, true)} +
@@ -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 ( + <>
{getLocalizedCityName( row.city, @@ -637,13 +607,19 @@ function CalendarView({ locale, )}
-
- {row.action || row.target_label || "--"} +
+ {locale === "en-US" ? "DEB high" : "DEB 预测高点"} ·{" "} + {row.deb_prediction != null + ? formatTemperatureValue(row.deb_prediction, tempSymbol) + : "--"}
{row.local_time || "--"} - {formatPercent(row.edge_percent, true)} + {phaseMeta.label}
+ + ); + })()} ))}
@@ -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 ( -
-
-
{locale === "en-US" ? "YES Ideas" : "YES 机会"}
-
{yesCount}
-
-
-
{locale === "en-US" ? "NO Ideas" : "NO 机会"}
-
{noCount}
-
-
-
{locale === "en-US" ? "Avg Score" : "平均评分"}
-
- {avgScore != null ? `${avgScore.toFixed(0)}/100` : "--"} -
-
-
- ); -} - -function MonitorView({ - rows, - locale, -}: { - rows: ScanOpportunityRow[]; - locale: string; -}) { - const groups = useMemo(() => { - const result = new Map(); - 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 ( -
- {groups.map(([label, count]) => ( -
-
{label}
-
{count}
-
- ))} -
- ); -} - -function SettingsView({ locale }: { locale: string }) { - return ( -
-
-
{locale === "en-US" ? "Market Discovery" : "市场发现"}
-
- {locale === "en-US" ? "Gamma REST refreshes every 60s." : "Gamma REST 每 60 秒刷新一次。"} -
-
-
-
{locale === "en-US" ? "Price Layer" : "价格层"}
-
- {locale === "en-US" - ? "CLOB REST prices and books refresh every 30s." - : "CLOB REST 价格和盘口每 30 秒刷新一次。"} -
-
-
-
{locale === "en-US" ? "Server Profile" : "服务器配置"}
-
- {locale === "en-US" - ? "2GB profile: one polling loop, cached REST, no websocket fan-out." - : "2G 配置:单轮询、REST 缓存优先、不做 websocket 扇出。"} -
-
-
- ); -} - function ScanTerminalScreen() { const store = useDashboardStore(); const { locale, toggleLocale } = useI18n(); @@ -776,7 +661,6 @@ function ScanTerminalScreen() { const [selectedRowId, setSelectedRowId] = useState(null); const [detailByRowId, setDetailByRowId] = useState>({}); const [detailLoadingId, setDetailLoadingId] = useState(null); - const [activeSection, setActiveSection] = useState("terminal"); const [activeView, setActiveView] = useState("list"); const [mapSelectedCityName, setMapSelectedCityName] = useState(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 ; - } - if (activeSection === "monitor") { - return ; - } - if (activeSection === "settings") { - return ; - } if (resolvedView === "map") { return (
@@ -951,6 +824,10 @@ function ScanTerminalScreen() { <> @@ -973,19 +850,21 @@ function ScanTerminalScreen() {
-
- {NAV_ITEMS.map((item, index) => ( - - ))} +
+ {isEn ? "Market Scan Terminal" : "市场扫描台"} + + {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 主峰优先"} +
-
-
-

{isEn ? "Tradable Opportunities" : "可交易机会"}

-

- {isEn - ? "Use EMOS distribution, live order book, and timing windows to isolate the one actionable signal." - : "基于当前时间、实况数据和模型预测,筛选出最具交易价值的市场。"} -

-
-
@@ -1058,9 +916,8 @@ function ScanTerminalScreen() {
-
-
- {isEn - ? resolvedView === "calendar" - ? "Sort: Date" - : resolvedView === "map" - ? "Sort: City Map" - : "Sort: Your Time" - : resolvedView === "calendar" - ? "排序:日期" - : resolvedView === "map" - ? "排序:地图" - : "排序:用户时区时间"} -
- +
+ {terminalData?.stale ? ( + + {isEn ? "Delayed snapshot" : "延迟快照"} + + ) : null} + {loading ? ( + + {isEn ? "Refreshing" : "刷新中"} + + ) : null}
- {error ? ( + {scanStatus === "failed" && !terminalData ? (
{isEn ? "Scan failed" : "扫描失败"}
-
{error}
+
{staleReason}
) : ( renderMainView() diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 6070fff7..38409344 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -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; diff --git a/src/data_collection/polymarket_readonly.py b/src/data_collection/polymarket_readonly.py index aca09423..d806b838 100644 --- a/src/data_collection/polymarket_readonly.py +++ b/src/data_collection/polymarket_readonly.py @@ -2810,12 +2810,60 @@ class PolymarketReadOnlyLayer: ) distribution_preview[highlighted_index]["highlighted"] = True + peak_probability = None + peak_value = None + if distribution_preview: + highlighted_preview = next( + (item for item in distribution_preview if item.get("highlighted")), + None, + ) + if isinstance(highlighted_preview, dict): + peak_probability = _safe_float(highlighted_preview.get("model_probability")) + peak_value = _safe_float(highlighted_preview.get("value")) + + ordered_entry_indices = sorted( + range(len(market_entries)), + key=lambda index: ( + _safe_float(market_entries[index].get("bucket_temp")) + if _safe_float(market_entries[index].get("bucket_temp")) is not None + else float("inf"), + str(market_entries[index].get("target_label") or ""), + ), + ) + entry_order_map = { + ordered_entry_indices[position]: position + for position in range(len(ordered_entry_indices)) + } + peak_entry_order = None + if peak_value is not None and ordered_entry_indices: + peak_entry_order = min( + range(len(ordered_entry_indices)), + key=lambda position: abs( + ( + _safe_float( + market_entries[ordered_entry_indices[position]].get("bucket_temp") + ) + if _safe_float( + market_entries[ordered_entry_indices[position]].get("bucket_temp") + ) + is not None + else peak_value + ) + - peak_value + ), + ) + current_reference_raw = _safe_float( (scan_context or {}).get("current_max_so_far") or (scan_context or {}).get("current_temp") ) - def _row_from_entry(entry: Dict[str, Any], side: str) -> Optional[Dict[str, Any]]: + def _row_from_entry( + entry: Dict[str, Any], + side: str, + *, + entry_index: int, + ) -> Optional[Dict[str, Any]]: model_event_probability = _clamp_probability(_safe_float(entry.get("model_event_probability"))) market_event_probability = _clamp_probability(_safe_float(entry.get("market_event_probability"))) ask = _clamp_probability(_safe_float(entry.get("yes_ask") if side == "yes" else entry.get("no_ask"))) @@ -2850,6 +2898,21 @@ class PolymarketReadOnlyLayer: if target_threshold is not None and current_reference is not None else None ) + entry_order = entry_order_map.get(entry_index) + peak_distance = None + is_peak_candidate = False + if entry_order is not None and peak_entry_order is not None: + peak_distance = abs(entry_order - peak_entry_order) + is_peak_candidate = peak_distance <= 1 + peak_alignment_score = 0.0 + if peak_distance is None: + peak_alignment_score = 0.35 + elif peak_distance == 0: + peak_alignment_score = 1.0 + elif peak_distance == 1: + peak_alignment_score = 0.8 + else: + peak_alignment_score = max(0.0, 0.55 - 0.15 * float(peak_distance - 2)) temperature_direction = self._resolve_temperature_direction( side=side, market_direction=str(entry.get("market_direction") or "exact"), @@ -2899,6 +2962,7 @@ class PolymarketReadOnlyLayer: + 0.20 * float(window_meta.get("score") or 0.0) + 0.10 * liquidity_score + 0.10 * price_usefulness_score + + 0.08 * peak_alignment_score ) - spread_penalty market_slug = str(market.get("slug") or "").strip() target_label = str(entry.get("target_label") or "").strip() @@ -2974,6 +3038,11 @@ class PolymarketReadOnlyLayer: "distribution_bias_score": distribution_bias_score, "distribution_bias_available": distribution_bias["available"], "distribution_preview": distribution_preview[:6], + "peak_probability": peak_probability, + "peak_value": peak_value, + "peak_distance": peak_distance, + "peak_alignment_score": peak_alignment_score, + "is_peak_candidate": is_peak_candidate, "current_reference": current_reference, "gap_to_target": gap_to_target, "touch_distance": abs(gap_to_target) if gap_to_target is not None else None, @@ -2991,9 +3060,9 @@ class PolymarketReadOnlyLayer: } preliminary_rows: List[Dict[str, Any]] = [] - for entry in market_entries: - row_yes = _row_from_entry(entry, "yes") - row_no = _row_from_entry(entry, "no") + for entry_index, entry in enumerate(market_entries): + row_yes = _row_from_entry(entry, "yes", entry_index=entry_index) + row_no = _row_from_entry(entry, "no", entry_index=entry_index) if row_yes: preliminary_rows.append(row_yes) if row_no: @@ -3041,9 +3110,9 @@ class PolymarketReadOnlyLayer: entry["spread"] = max(0.0, float(entry["yes_ask"]) - float(entry["yes_bid"])) final_rows: List[Dict[str, Any]] = [] - for entry in market_entries: + for entry_index, entry in enumerate(market_entries): for side in ("yes", "no"): - row = _row_from_entry(entry, side) + row = _row_from_entry(entry, side, entry_index=entry_index) if row: final_rows.append(row) @@ -3074,7 +3143,10 @@ class PolymarketReadOnlyLayer: def _passes_mode_filters(row: Dict[str, Any]) -> bool: scan_mode = filters["scan_mode"] if scan_mode == "tradable": - return float(row.get("window_score") or 0.0) >= 0.65 + return ( + float(row.get("window_score") or 0.0) >= 0.65 + and bool(row.get("is_peak_candidate")) + ) if scan_mode == "early": return str(row.get("window_phase") or "") in {"tomorrow", "week_ahead", "early_today"} if scan_mode == "touch": @@ -3095,6 +3167,7 @@ class PolymarketReadOnlyLayer: ] filtered_rows.sort( key=lambda row: ( + 1.0 if bool(row.get("is_peak_candidate")) else 0.0, float(row.get("final_score") or 0.0), float(row.get("edge_percent") or 0.0), ), diff --git a/tests/test_polymarket_readonly.py b/tests/test_polymarket_readonly.py index 07b862bf..388cf006 100644 --- a/tests/test_polymarket_readonly.py +++ b/tests/test_polymarket_readonly.py @@ -675,6 +675,39 @@ def test_distribution_scan_hard_filters_block_unusable_extreme_quotes(): assert scan["rows"] == [] +def test_distribution_scan_tradable_prefers_peak_bucket_and_adjacent_only(): + layer, markets = _build_scan_test_layer() + + scan = layer._build_distribution_scan_pack( + city_key="wellington", + target_date="2026-04-24", + primary_market=markets[0], + probability_distribution=[ + {"value": 14, "probability": 20}, + {"value": 15, "probability": 48}, + {"value": 16, "probability": 24}, + {"value": 17, "probability": 8}, + ], + temp_symbol="°C", + scan_context={ + "local_date": "2026-04-24", + "local_time": "13:10", + "peak": {"first_h": 14, "last_h": 16}, + "current_max_so_far": 13.6, + "current_temp": 13.2, + "trend": {"recent": []}, + "network_lead_signal": {}, + }, + scan_filters={"limit": 10, "scan_mode": "tradable", "min_edge_pct": 2}, + ) + + assert scan["signal_status"] == "ready" + assert scan["primary_signal"]["is_peak_candidate"] is True + assert scan["primary_signal"]["peak_distance"] in {0, 1} + assert all(bool(row.get("is_peak_candidate")) for row in scan["rows"]) + assert all((row.get("peak_distance") or 0) <= 1 for row in scan["rows"]) + + def test_batch_token_market_data_falls_back_to_single_fetch_when_batch_fails(): layer = PolymarketReadOnlyLayer() layer._clob_post = lambda *_args, **_kwargs: None diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 8f18a6f7..b3aa4d2f 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -3,6 +3,7 @@ from fastapi.testclient import TestClient from web.app import app import web.routes as routes +import web.scan_terminal_service as scan_terminal_service from web.scan_terminal_service import _scan_terminal_cache_key from src.database.db_manager import DBManager from src.database.runtime_state import TruthRecordRepository, TrainingFeatureRecordRepository @@ -258,7 +259,66 @@ def test_ops_truth_history_returns_filtered_rows(monkeypatch): assert "items" in payload assert payload["filters"]["city"] == "taipei" assert payload["items"][0]["city"] == "taipei" - assert payload["items"][0]["settlement_station_code"] == "RCSS" + + +def test_scan_terminal_service_returns_stale_payload_after_failed_refresh(monkeypatch): + filters = {"scan_mode": "tradable", "limit": 5} + normalized_filters = scan_terminal_service._normalize_scan_terminal_filters(filters) + scan_terminal_service._SCAN_TERMINAL_CACHE.clear() + + monkeypatch.setattr( + scan_terminal_service, + "_scan_city_terminal_rows", + lambda *_args, **_kwargs: { + "city": "taipei", + "rows": [ + { + "id": "row-1", + "market_key": "market-1", + "edge_percent": 12.4, + "final_score": 83.0, + "volume": 2000, + } + ], + "candidate_total": 1, + "primary_scores": [83.0], + }, + ) + + ready = scan_terminal_service.build_scan_terminal_payload(filters, force_refresh=True) + assert ready["status"] == "ready" + assert ready["rows"][0]["id"] == "row-1" + + def _explode(*_args, **_kwargs): + raise RuntimeError("upstream 504") + + monkeypatch.setattr(scan_terminal_service, "_scan_city_terminal_rows", _explode) + + stale = scan_terminal_service.build_scan_terminal_payload(filters, force_refresh=True) + + assert stale["status"] == "stale" + assert stale["stale"] is True + assert stale["rows"][0]["id"] == "row-1" + assert stale["filters"] == normalized_filters + assert stale["stale_reason"] == "upstream 504" + + +def test_scan_terminal_service_returns_failed_without_success_snapshot(monkeypatch): + filters = {"scan_mode": "tradable", "limit": 5} + scan_terminal_service._SCAN_TERMINAL_CACHE.clear() + + def _explode(*_args, **_kwargs): + raise RuntimeError("network down") + + monkeypatch.setattr(scan_terminal_service, "_scan_city_terminal_rows", _explode) + + failed = scan_terminal_service.build_scan_terminal_payload(filters, force_refresh=True) + + assert failed["status"] == "failed" + assert failed["stale"] is False + assert failed["rows"] == [] + assert failed["summary"]["candidate_total"] == 0 + assert failed["stale_reason"] == "network down" def test_scan_terminal_endpoint_forwards_filters(monkeypatch): diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index fd5be9b5..027789b0 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -4,6 +4,7 @@ import json import os import threading import time +import hashlib from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta from typing import Any, Dict, List, Optional @@ -99,18 +100,125 @@ def _get_cached_scan_terminal_payload( return dict(payload) +def _get_scan_terminal_cache_entry(filters: Dict[str, Any]) -> Optional[Dict[str, Any]]: + cache_key = _scan_terminal_cache_key(filters) + with _SCAN_TERMINAL_CACHE_LOCK: + cached = _SCAN_TERMINAL_CACHE.get(cache_key) + if not isinstance(cached, dict): + return None + return dict(cached) + + +def _build_scan_terminal_snapshot_id( + filters: Dict[str, Any], + rows: List[Dict[str, Any]], + summary: Dict[str, Any], + top_signal: Optional[Dict[str, Any]], +) -> str: + seed_payload = { + "filters": filters, + "summary": { + "candidate_total": summary.get("candidate_total"), + "tradable_market_count": summary.get("tradable_market_count"), + "avg_edge_percent": summary.get("avg_edge_percent"), + }, + "top_signal": { + "id": (top_signal or {}).get("id"), + "edge_percent": (top_signal or {}).get("edge_percent"), + "final_score": (top_signal or {}).get("final_score"), + }, + "rows": [ + { + "id": row.get("id"), + "edge_percent": row.get("edge_percent"), + "final_score": row.get("final_score"), + } + for row in rows[:10] + ], + } + digest = hashlib.md5( + json.dumps(seed_payload, ensure_ascii=True, sort_keys=True).encode("utf-8") + ).hexdigest() + return f"scan-{digest[:10]}" + + def _set_cached_scan_terminal_payload( filters: Dict[str, Any], payload: Dict[str, Any], ) -> None: cache_key = _scan_terminal_cache_key(filters) + existing = _get_scan_terminal_cache_entry(filters) or {} with _SCAN_TERMINAL_CACHE_LOCK: _SCAN_TERMINAL_CACHE[cache_key] = { "t": time.time(), "payload": dict(payload), + "success_t": time.time(), + "success_payload": dict(payload), + "last_error": existing.get("last_error"), + "last_failed_at": existing.get("last_failed_at"), } +def _set_scan_terminal_failure_state( + filters: Dict[str, Any], + *, + error_message: str, +) -> None: + cache_key = _scan_terminal_cache_key(filters) + with _SCAN_TERMINAL_CACHE_LOCK: + existing = _SCAN_TERMINAL_CACHE.get(cache_key) or {} + existing["last_error"] = error_message + existing["last_failed_at"] = datetime.utcnow().isoformat() + "Z" + _SCAN_TERMINAL_CACHE[cache_key] = existing + + +def _build_stale_scan_terminal_payload( + *, + filters: Dict[str, Any], + success_payload: Dict[str, Any], + error_message: str, + failed_at: Optional[str], +) -> Dict[str, Any]: + payload = dict(success_payload) + payload["status"] = "stale" + payload["stale"] = True + payload["stale_reason"] = error_message + payload["last_success_at"] = success_payload.get("generated_at") + payload["last_failed_at"] = failed_at + payload["filters"] = filters + return payload + + +def _build_failed_scan_terminal_payload( + *, + filters: Dict[str, Any], + error_message: str, + failed_at: Optional[str] = None, +) -> Dict[str, Any]: + return { + "generated_at": datetime.utcnow().isoformat() + "Z", + "snapshot_id": None, + "status": "failed", + "stale": False, + "stale_reason": error_message, + "last_success_at": None, + "last_failed_at": failed_at or (datetime.utcnow().isoformat() + "Z"), + "filters": filters, + "summary": { + "recommended_count": 0, + "visible_count": 0, + "candidate_total": 0, + "avg_edge_percent": None, + "avg_primary_confidence": None, + "tradable_market_count": 0, + "total_volume": 0.0, + "resolved_market_type": "maxtemp", + }, + "top_signal": None, + "rows": [], + } + + def _resolve_time_range_dates(data: Dict[str, Any], time_range: str) -> List[str]: local_date = str(data.get("local_date") or "").strip() multi_model_daily = data.get("multi_model_daily") or {} @@ -265,83 +373,105 @@ def build_scan_terminal_payload( cached = _get_cached_scan_terminal_payload(filters) if cached is not None: return cached + cached_entry = _get_scan_terminal_cache_entry(filters) or {} - city_names = list(CITIES.keys()) - max_workers = max(1, min(6, len(city_names))) - city_results: List[Dict[str, Any]] = [] + try: + city_names = list(CITIES.keys()) + max_workers = max(1, min(4, len(city_names))) + city_results: List[Dict[str, Any]] = [] + failed_cities: List[str] = [] + failed_reasons: List[str] = [] - with ThreadPoolExecutor(max_workers=max_workers) as executor: - future_map = { - executor.submit( - _scan_city_terminal_rows, - city_name, - filters, - force_refresh=force_refresh, - ): city_name - for city_name in city_names - } - for future in as_completed(future_map): - city_name = future_map[future] - try: - city_results.append(future.result()) - except Exception as exc: - logger.warning("scan terminal city failed city={}: {}", city_name, exc) - - primary_rows: List[Dict[str, Any]] = [] - primary_scores: List[float] = [] - candidate_total = 0 - - for result in city_results: - candidate_total += int(result.get("candidate_total") or 0) - primary_rows.extend(result.get("rows") or []) - primary_scores.extend(result.get("primary_scores") or []) - - primary_rows.sort( - key=lambda row: ( - float(row.get("final_score") or 0.0), - float(row.get("edge_percent") or 0.0), - ), - reverse=True, - ) - - ranked_rows: List[Dict[str, Any]] = [] - for index, row in enumerate(primary_rows[: filters["limit"]], start=1): - ranked_rows.append( - { - **row, - "rank": index, + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_map = { + executor.submit( + _scan_city_terminal_rows, + city_name, + filters, + force_refresh=force_refresh, + ): city_name + for city_name in city_names } + for future in as_completed(future_map): + city_name = future_map[future] + try: + city_results.append(future.result()) + except Exception as exc: + failed_cities.append(city_name) + failed_reasons.append(str(exc)) + logger.warning("scan terminal city failed city={}: {}", city_name, exc) + + if city_names and len(failed_cities) >= len(city_names): + error_message = failed_reasons[0] if failed_reasons else "all city market scans failed" + _set_scan_terminal_failure_state(filters, error_message=error_message) + failed_entry = _get_scan_terminal_cache_entry(filters) or {} + success_payload = failed_entry.get("success_payload") + failed_at = failed_entry.get("last_failed_at") + if isinstance(success_payload, dict) and success_payload: + return _build_stale_scan_terminal_payload( + filters=filters, + success_payload=success_payload, + error_message=error_message, + failed_at=failed_at, + ) + return _build_failed_scan_terminal_payload( + filters=filters, + error_message=error_message, + failed_at=failed_at, + ) + + primary_rows: List[Dict[str, Any]] = [] + primary_scores: List[float] = [] + candidate_total = 0 + + for result in city_results: + candidate_total += int(result.get("candidate_total") or 0) + primary_rows.extend(result.get("rows") or []) + primary_scores.extend(result.get("primary_scores") or []) + + primary_rows.sort( + key=lambda row: ( + float(row.get("final_score") or 0.0), + float(row.get("edge_percent") or 0.0), + ), + reverse=True, ) - unique_market_volume: Dict[str, float] = {} - for row in primary_rows: - market_key = str(row.get("market_key") or row.get("id") or "").strip() - if not market_key: - continue - unique_market_volume[market_key] = max( - unique_market_volume.get(market_key, 0.0), - float(row.get("volume") or 0.0), - ) + ranked_rows: List[Dict[str, Any]] = [] + for index, row in enumerate(primary_rows[: filters["limit"]], start=1): + ranked_rows.append( + { + **row, + "rank": index, + } + ) - avg_edge = None - if primary_rows: - edge_values = [ - float(row.get("edge_percent") or 0.0) - for row in primary_rows - if _safe_float(row.get("edge_percent")) is not None - ] - if edge_values: - avg_edge = sum(edge_values) / len(edge_values) + unique_market_volume: Dict[str, float] = {} + for row in primary_rows: + market_key = str(row.get("market_key") or row.get("id") or "").strip() + if not market_key: + continue + unique_market_volume[market_key] = max( + unique_market_volume.get(market_key, 0.0), + float(row.get("volume") or 0.0), + ) - avg_confidence = None - if primary_scores: - avg_confidence = sum(primary_scores) / len(primary_scores) + avg_edge = None + if primary_rows: + edge_values = [ + float(row.get("edge_percent") or 0.0) + for row in primary_rows + if _safe_float(row.get("edge_percent")) is not None + ] + if edge_values: + avg_edge = sum(edge_values) / len(edge_values) - top_signal = ranked_rows[0] if ranked_rows else None - payload = { - "generated_at": datetime.utcnow().isoformat() + "Z", - "filters": filters, - "summary": { + avg_confidence = None + if primary_scores: + avg_confidence = sum(primary_scores) / len(primary_scores) + + top_signal = ranked_rows[0] if ranked_rows else None + summary = { "recommended_count": len(primary_rows), "visible_count": len(ranked_rows), "candidate_total": candidate_total, @@ -350,10 +480,43 @@ def build_scan_terminal_payload( "tradable_market_count": len(unique_market_volume), "total_volume": sum(unique_market_volume.values()), "resolved_market_type": "maxtemp", - }, - "top_signal": top_signal, - "rows": ranked_rows, - } + } + payload = { + "generated_at": datetime.utcnow().isoformat() + "Z", + "filters": filters, + "summary": summary, + "top_signal": top_signal, + "rows": ranked_rows, + "status": "ready", + "stale": False, + "stale_reason": None, + "last_success_at": None, + "last_failed_at": None, + } + payload["snapshot_id"] = _build_scan_terminal_snapshot_id( + filters, + ranked_rows, + summary, + top_signal, + ) - _set_cached_scan_terminal_payload(filters, payload) - return payload + _set_cached_scan_terminal_payload(filters, payload) + return payload + except Exception as exc: + error_message = str(exc) + logger.exception("scan terminal payload build failed: {}", error_message) + _set_scan_terminal_failure_state(filters, error_message=error_message) + success_payload = cached_entry.get("success_payload") + failed_at = _get_scan_terminal_cache_entry(filters).get("last_failed_at") if _get_scan_terminal_cache_entry(filters) else None + if isinstance(success_payload, dict) and success_payload: + return _build_stale_scan_terminal_payload( + filters=filters, + success_payload=success_payload, + error_message=error_message, + failed_at=failed_at, + ) + return _build_failed_scan_terminal_payload( + filters=filters, + error_message=error_message, + failed_at=failed_at, + )