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]) => (
-
- ))}
-
- );
-}
-
-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() {
{
- 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" : "日历视图"}
-
-
- {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,
+ )