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;
+80 -7
View File
@@ -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),
),
+33
View File
@@ -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
+61 -1
View File
@@ -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):
+238 -75
View File
@@ -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,
)