diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css
index 0e27f93d..db073e50 100644
--- a/frontend/components/dashboard/Dashboard.module.css
+++ b/frontend/components/dashboard/Dashboard.module.css
@@ -9600,6 +9600,12 @@
color: #87fff1;
}
+.root :global(.scan-status-chip.focus) {
+ border-color: rgba(255, 176, 32, 0.3);
+ background: rgba(255, 176, 32, 0.12);
+ color: #ffd078;
+}
+
.root :global(.scan-table-shell) {
display: flex;
flex-direction: column;
diff --git a/frontend/components/dashboard/ScanFilterPanel.tsx b/frontend/components/dashboard/ScanFilterPanel.tsx
index 81ddad84..5cc5db7d 100644
--- a/frontend/components/dashboard/ScanFilterPanel.tsx
+++ b/frontend/components/dashboard/ScanFilterPanel.tsx
@@ -5,7 +5,6 @@ import {
CircleDot,
Clock3,
Info,
- Search,
TrendingUp,
Zap,
} from "lucide-react";
@@ -52,13 +51,9 @@ const SCAN_MODES = [
export function ScanFilterPanel({
value,
onChange,
- onScan,
- isScanning,
}: {
value: FilterState;
onChange?: (filters: FilterState) => void;
- onScan?: (filters: FilterState) => void;
- isScanning?: boolean;
}) {
const { locale } = useI18n();
const isEn = locale === "en-US";
@@ -114,21 +109,6 @@ export function ScanFilterPanel({
-
);
}
diff --git a/frontend/components/dashboard/ScanKPIBar.tsx b/frontend/components/dashboard/ScanKPIBar.tsx
index 23ed8ba1..b95f3cfe 100644
--- a/frontend/components/dashboard/ScanKPIBar.tsx
+++ b/frontend/components/dashboard/ScanKPIBar.tsx
@@ -3,6 +3,7 @@
import React from "react";
import { useI18n } from "@/hooks/useI18n";
import type { ScanOpportunityRow, ScanTerminalResponse } from "@/lib/dashboard-types";
+import { getMarketFocus } from "@/lib/scan-market-focus";
function formatPercent(value?: number | null, signed = false): string {
if (value == null || Number.isNaN(Number(value))) return "--";
@@ -41,6 +42,7 @@ export function ScanKPIBar({
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 marketFocus = getMarketFocus(rows, locale);
const statusLabel =
loading && !response
? isEn
@@ -91,10 +93,14 @@ export function ScanKPIBar({
tone: "green",
},
{
- label: isEn ? "Risk Layers" : "风险层",
- value: `${riskCounts.high} / ${riskCounts.medium} / ${riskCounts.low}`,
- note: `${isEn ? "High / Med / Low" : "高 / 中 / 低"}`,
- tone: "purple",
+ label: isEn ? "Current Region" : "当前主盘",
+ value: marketFocus?.label || "--",
+ note: marketFocus
+ ? isEn
+ ? `${marketFocus.stageLabel} · ${marketFocus.activeCityCount || 1} cities in focus`
+ : `${marketFocus.stageLabel} · ${marketFocus.activeCityCount || 1} 个城市在焦点窗口`
+ : `${isEn ? "Risk" : "风险"} ${riskCounts.high} / ${riskCounts.medium} / ${riskCounts.low}`,
+ tone: "orange",
},
];
diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx
index 557c9d71..b638cb14 100644
--- a/frontend/components/dashboard/ScanTerminalDashboard.tsx
+++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx
@@ -44,6 +44,11 @@ import {
formatTemperatureValue,
normalizeTemperatureLabel,
} from "@/lib/dashboard-utils";
+import {
+ getMarketFocus,
+ getRowMarketRegion,
+ getRowPeakSortValue,
+} from "@/lib/scan-market-focus";
const DEFAULT_FILTERS: FilterState = {
scan_mode: "tradable",
@@ -222,15 +227,31 @@ function getPhaseUrgency(row: ScanOpportunityRow) {
const phase = String(row.window_phase || "").toLowerCase();
if (phase === "active_peak") return 0;
if (phase === "setup_today") return 1;
- if (phase === "early_today") return 2;
- if (phase === "post_peak") return 3;
+ if (phase === "post_peak") return 2;
+ if (phase === "early_today") return 3;
if (phase === "tomorrow") return 4;
if (phase === "week_ahead") return 5;
return 6;
}
function sortRowsByUserTime(rows: ScanOpportunityRow[]) {
+ const focus = getMarketFocus(rows);
return [...rows].sort((left, right) => {
+ if (focus) {
+ const leftFocusRank = getRowMarketRegion(left) === focus.key ? 0 : 1;
+ const rightFocusRank = getRowMarketRegion(right) === focus.key ? 0 : 1;
+ if (leftFocusRank !== rightFocusRank) return leftFocusRank - rightFocusRank;
+ }
+
+ const leftPeakSort = getRowPeakSortValue(left);
+ const rightPeakSort = getRowPeakSortValue(right);
+ if (leftPeakSort.stage.rank !== rightPeakSort.stage.rank) {
+ return leftPeakSort.stage.rank - rightPeakSort.stage.rank;
+ }
+ if (leftPeakSort.countdown !== rightPeakSort.countdown) {
+ return leftPeakSort.countdown - rightPeakSort.countdown;
+ }
+
const leftDateIndex = getLocalDateIndex(left.selected_date || left.local_date);
const rightDateIndex = getLocalDateIndex(right.selected_date || right.local_date);
if (leftDateIndex !== rightDateIndex) return leftDateIndex - rightDateIndex;
@@ -253,6 +274,31 @@ function sortRowsByUserTime(rows: ScanOpportunityRow[]) {
});
}
+function mergeRefreshingScanSnapshot(
+ previous: ScanTerminalResponse | null,
+ next: ScanTerminalResponse,
+ locale = "zh-CN",
+): ScanTerminalResponse {
+ if (next.rows.length || !previous?.rows.length) return next;
+ const nextStatus = String(next.status || "").toLowerCase();
+ if (!["partial", "stale", "scanning", "loading", "failed"].includes(nextStatus)) {
+ return next;
+ }
+ return {
+ ...previous,
+ generated_at: previous.generated_at || next.generated_at,
+ status: nextStatus === "failed" ? "stale" : next.status || "stale",
+ stale: true,
+ stale_reason:
+ next.stale_reason ||
+ (locale === "en-US"
+ ? "Refreshing a new scan; showing the previous snapshot until it is ready."
+ : "新扫描仍在刷新中,当前继续展示上一轮快照。"),
+ last_failed_at: next.last_failed_at || previous.last_failed_at,
+ filters: next.filters || previous.filters,
+ };
+}
+
function normalizeCityKey(value?: string | null) {
return String(value || "")
.trim()
@@ -671,6 +717,10 @@ function ScanTerminalScreen() {
() => sortRowsByUserTime(deferredRows),
[deferredRows],
);
+ const marketFocus = useMemo(
+ () => getMarketFocus(timeSortedRows, locale),
+ [locale, timeSortedRows],
+ );
const selectedRow = useMemo(() => {
if (!timeSortedRows.length) return null;
@@ -778,26 +828,31 @@ function ScanTerminalScreen() {
setAiError(null);
try {
const response = await dashboardClient.getScanTerminal(filters, { force });
+ const displayRows = response.rows.length
+ ? response.rows
+ : terminalData?.rows || [];
startTransition(() => {
- setTerminalData(response);
+ setTerminalData((current) =>
+ mergeRefreshingScanSnapshot(current, response, locale),
+ );
setActiveFilters(filters);
setError(response.status === "failed" ? response.stale_reason || null : null);
setSelectedRowId((current) => {
- if (current && response.rows.some((row) => row.id === current)) {
+ if (current && displayRows.some((row) => row.id === current)) {
return current;
}
- return sortRowsByUserTime(response.rows)[0]?.id || response.top_signal?.id || null;
+ return sortRowsByUserTime(displayRows)[0]?.id || response.top_signal?.id || null;
});
});
prependAiLogs([
{
id: `rule-${Date.now()}`,
time: formatLogTime(),
- tone: response.rows.length ? "success" : "warning",
+ tone: displayRows.length ? "success" : "warning",
title: isEn ? "Rule scan snapshot ready" : "规则扫描快照已就绪",
detail: isEn
- ? `snapshot ${response.snapshot_id || "--"} · ${response.rows.length} visible rows`
- : `快照 ${response.snapshot_id || "--"} · 可见候选 ${response.rows.length} 条`,
+ ? `snapshot ${response.snapshot_id || "--"} · ${displayRows.length} visible rows`
+ : `快照 ${response.snapshot_id || "--"} · 可见候选 ${displayRows.length} 条`,
},
]);
} catch (fetchError) {
@@ -1248,6 +1303,13 @@ function ScanTerminalScreen() {
+ {marketFocus ? (
+
+ {isEn
+ ? `Focus: ${marketFocus.label}`
+ : `当前主盘:${marketFocus.label}`}
+
+ ) : null}
{terminalData?.stale ? (
{isEn ? "Delayed snapshot" : "延迟快照"}
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index b6317c72..6321f86f 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -445,6 +445,10 @@ export interface ScanOpportunityRow {
city: string;
city_display_name?: string | null;
display_name?: string | null;
+ trading_region?: string | null;
+ trading_region_label?: string | null;
+ trading_region_label_zh?: string | null;
+ tz_offset_seconds?: number | null;
selected_date?: string | null;
local_date?: string | null;
local_time?: string | null;
diff --git a/frontend/lib/scan-market-focus.ts b/frontend/lib/scan-market-focus.ts
new file mode 100644
index 00000000..d02bf3be
--- /dev/null
+++ b/frontend/lib/scan-market-focus.ts
@@ -0,0 +1,337 @@
+import type { ScanOpportunityRow } from "@/lib/dashboard-types";
+
+export type MarketRegionKey =
+ | "americas"
+ | "europe_africa"
+ | "asia_pacific"
+ | "unknown";
+
+type RegionMeta = {
+ key: MarketRegionKey;
+ labelEn: string;
+ labelZh: string;
+};
+
+export type RowTradingStage = {
+ key: string;
+ rank: number;
+ score: number;
+ labelEn: string;
+ labelZh: string;
+};
+
+export type MarketFocus = {
+ key: MarketRegionKey;
+ label: string;
+ labelEn: string;
+ labelZh: string;
+ stageLabel: string;
+ activeCityCount: number;
+ opportunityCount: number;
+ score: number;
+ leadRow: ScanOpportunityRow | null;
+};
+
+const REGION_META: Record = {
+ americas: {
+ key: "americas",
+ labelEn: "Americas",
+ labelZh: "美洲",
+ },
+ europe_africa: {
+ key: "europe_africa",
+ labelEn: "Europe / Africa",
+ labelZh: "欧洲 / 非洲",
+ },
+ asia_pacific: {
+ key: "asia_pacific",
+ labelEn: "Asia-Pacific",
+ labelZh: "亚太",
+ },
+ unknown: {
+ key: "unknown",
+ labelEn: "Global",
+ labelZh: "全球",
+ },
+};
+
+const CITY_REGION_FALLBACK: Record = {
+ "new york": "americas",
+ toronto: "americas",
+ "los angeles": "americas",
+ "san francisco": "americas",
+ aurora: "americas",
+ denver: "americas",
+ austin: "americas",
+ houston: "americas",
+ "mexico city": "americas",
+ chicago: "americas",
+ dallas: "americas",
+ miami: "americas",
+ atlanta: "americas",
+ seattle: "americas",
+ "panama city": "americas",
+ "buenos aires": "americas",
+ "sao paulo": "americas",
+ london: "europe_africa",
+ paris: "europe_africa",
+ istanbul: "europe_africa",
+ ankara: "europe_africa",
+ moscow: "europe_africa",
+ helsinki: "europe_africa",
+ amsterdam: "europe_africa",
+ munich: "europe_africa",
+ milan: "europe_africa",
+ warsaw: "europe_africa",
+ madrid: "europe_africa",
+ lagos: "europe_africa",
+ "cape town": "europe_africa",
+ jeddah: "europe_africa",
+ "tel aviv": "europe_africa",
+ seoul: "asia_pacific",
+ busan: "asia_pacific",
+ "hong kong": "asia_pacific",
+ "lau fau shan": "asia_pacific",
+ taipei: "asia_pacific",
+ shanghai: "asia_pacific",
+ singapore: "asia_pacific",
+ "kuala lumpur": "asia_pacific",
+ jakarta: "asia_pacific",
+ manila: "asia_pacific",
+ karachi: "asia_pacific",
+ "masroor air base": "asia_pacific",
+ tokyo: "asia_pacific",
+ wellington: "asia_pacific",
+ lucknow: "asia_pacific",
+ chengdu: "asia_pacific",
+ chongqing: "asia_pacific",
+ shenzhen: "asia_pacific",
+ guangzhou: "asia_pacific",
+ beijing: "asia_pacific",
+ wuhan: "asia_pacific",
+};
+
+function normalizeKey(value?: string | null) {
+ return String(value || "")
+ .trim()
+ .toLowerCase()
+ .replace(/[_-]+/g, " ")
+ .replace(/\s+/g, " ");
+}
+
+function finiteNumber(value: unknown) {
+ const numeric = Number(value);
+ return Number.isFinite(numeric) ? numeric : null;
+}
+
+function clamp(value: number, min: number, max: number) {
+ return Math.max(min, Math.min(max, value));
+}
+
+export function getMarketRegionMeta(
+ key?: string | null,
+ locale = "zh-CN",
+): RegionMeta & { label: string } {
+ const normalized = normalizeKey(key).replace(/\s+/g, "_") as MarketRegionKey;
+ const meta = REGION_META[normalized] || REGION_META.unknown;
+ return {
+ ...meta,
+ label: locale === "en-US" ? meta.labelEn : meta.labelZh,
+ };
+}
+
+export function getRowMarketRegion(row: ScanOpportunityRow): MarketRegionKey {
+ const direct = normalizeKey(row.trading_region).replace(/\s+/g, "_");
+ if (direct in REGION_META) return direct as MarketRegionKey;
+
+ const offset = finiteNumber(row.tz_offset_seconds);
+ if (offset !== null) {
+ if (offset <= -7200) return "americas";
+ if (offset >= 14400) return "asia_pacific";
+ return "europe_africa";
+ }
+
+ const cityKey = normalizeKey(row.city || row.city_display_name || row.display_name);
+ return CITY_REGION_FALLBACK[cityKey] || "unknown";
+}
+
+export function getRowTradingStage(row: ScanOpportunityRow): RowTradingStage {
+ const phase = normalizeKey(row.window_phase);
+ const startDelta = finiteNumber(row.minutes_until_peak_start);
+ const endDelta = finiteNumber(row.minutes_until_peak_end);
+
+ if (
+ phase === "active peak" ||
+ phase === "active_peak" ||
+ (startDelta !== null && startDelta <= 0 && endDelta !== null && endDelta >= -120)
+ ) {
+ return {
+ key: "active_peak",
+ rank: 0,
+ score: 4,
+ labelEn: "Peak / settle window",
+ labelZh: "峰值 / 结算窗口",
+ };
+ }
+
+ if (
+ phase === "setup today" ||
+ phase === "setup_today" ||
+ (startDelta !== null && startDelta > 0 && startDelta <= 180)
+ ) {
+ return {
+ key: "setup_today",
+ rank: 1,
+ score: 3,
+ labelEn: "Pre-peak setup",
+ labelZh: "峰值前准备",
+ };
+ }
+
+ if (
+ phase === "post peak" ||
+ phase === "post_peak" ||
+ (endDelta !== null && endDelta < -120 && endDelta >= -300)
+ ) {
+ return {
+ key: "post_peak",
+ rank: 2,
+ score: 2,
+ labelEn: "Post-peak confirmation",
+ labelZh: "峰值后确认",
+ };
+ }
+
+ if (phase === "early today" || phase === "early_today") {
+ return {
+ key: "early_today",
+ rank: 3,
+ score: 0.5,
+ labelEn: "Early session",
+ labelZh: "早盘预备",
+ };
+ }
+
+ if (phase === "tomorrow") {
+ return {
+ key: "tomorrow",
+ rank: 4,
+ score: 0.25,
+ labelEn: "Next session",
+ labelZh: "下一交易日",
+ };
+ }
+
+ return {
+ key: phase || "unknown",
+ rank: 5,
+ score: 0,
+ labelEn: "Outside active window",
+ labelZh: "非活跃窗口",
+ };
+}
+
+export function getMarketFocus(
+ rows: ScanOpportunityRow[],
+ locale = "zh-CN",
+): MarketFocus | null {
+ if (!rows.length) return null;
+
+ const regions = new Map<
+ MarketRegionKey,
+ {
+ maxStageScore: number;
+ maxStageRank: number;
+ score: number;
+ activeCities: Set;
+ rows: ScanOpportunityRow[];
+ leadRow: ScanOpportunityRow | null;
+ leadStage: RowTradingStage | null;
+ }
+ >();
+
+ for (const row of rows) {
+ const region = getRowMarketRegion(row);
+ const stage = getRowTradingStage(row);
+ const current =
+ regions.get(region) ||
+ {
+ maxStageScore: 0,
+ maxStageRank: 99,
+ score: 0,
+ activeCities: new Set(),
+ rows: [],
+ leadRow: null,
+ leadStage: null,
+ };
+ const opportunityScore =
+ clamp(Number(row.final_score || 0) / 100, 0, 1) +
+ clamp(Number(row.edge_percent || 0) / 40, 0, 1);
+ current.rows.push(row);
+ current.score += opportunityScore;
+ if (stage.score > current.maxStageScore) {
+ current.maxStageScore = stage.score;
+ current.maxStageRank = stage.rank;
+ current.leadRow = row;
+ current.leadStage = stage;
+ current.activeCities.clear();
+ }
+ if (stage.score === current.maxStageScore) {
+ current.activeCities.add(normalizeKey(row.city || row.city_display_name));
+ if (
+ !current.leadRow ||
+ Number(row.final_score || 0) > Number(current.leadRow.final_score || 0)
+ ) {
+ current.leadRow = row;
+ current.leadStage = stage;
+ }
+ }
+ regions.set(region, current);
+ }
+
+ const ranked = [...regions.entries()].sort((left, right) => {
+ const [, leftValue] = left;
+ const [, rightValue] = right;
+ const stageDelta = rightValue.maxStageScore - leftValue.maxStageScore;
+ if (stageDelta !== 0) return stageDelta;
+ const rankDelta = leftValue.maxStageRank - rightValue.maxStageRank;
+ if (rankDelta !== 0) return rankDelta;
+ const activeDelta = rightValue.activeCities.size - leftValue.activeCities.size;
+ if (activeDelta !== 0) return activeDelta;
+ return rightValue.score - leftValue.score;
+ });
+
+ const [key, value] = ranked[0] || [];
+ if (!key || !value) return null;
+ const meta = getMarketRegionMeta(key, locale);
+ const stage = value.leadStage || getRowTradingStage(value.leadRow || rows[0]);
+
+ return {
+ key,
+ label: meta.label,
+ labelEn: meta.labelEn,
+ labelZh: meta.labelZh,
+ stageLabel: locale === "en-US" ? stage.labelEn : stage.labelZh,
+ activeCityCount: value.activeCities.size,
+ opportunityCount: value.rows.length,
+ score: value.maxStageScore * 100 + value.activeCities.size * 10 + value.score,
+ leadRow: value.leadRow,
+ };
+}
+
+export function getRowPeakSortValue(row: ScanOpportunityRow) {
+ const stage = getRowTradingStage(row);
+ const startDelta = finiteNumber(row.minutes_until_peak_start);
+ const endDelta = finiteNumber(row.minutes_until_peak_end);
+ const remaining = finiteNumber(row.remaining_window_minutes);
+ const countdown =
+ stage.key === "active_peak"
+ ? remaining ?? Math.abs(endDelta ?? 0)
+ : stage.key === "post_peak"
+ ? Math.abs(endDelta ?? 0)
+ : Math.abs(startDelta ?? remaining ?? 9999);
+ return {
+ stage,
+ countdown,
+ };
+}
diff --git a/src/data_collection/polymarket_readonly.py b/src/data_collection/polymarket_readonly.py
index 5c12ca33..0eb18ce0 100644
--- a/src/data_collection/polymarket_readonly.py
+++ b/src/data_collection/polymarket_readonly.py
@@ -3343,6 +3343,15 @@ class PolymarketReadOnlyLayer:
return False
if edge_percent < filters["min_edge_pct"]:
return False
+ side = str(row.get("side") or "").lower()
+ market_direction = str(row.get("market_direction") or "").lower()
+ if (
+ side == "no"
+ and market_direction in {"exact", "range"}
+ and ask >= 0.80
+ and edge_percent < 10.0
+ ):
+ return False
if spread is None or spread > filters["max_spread"]:
return False
if liquidity < filters["min_liquidity"]:
diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py
index ef5b53cb..c427b401 100644
--- a/web/scan_terminal_service.py
+++ b/web/scan_terminal_service.py
@@ -111,6 +111,27 @@ def _normalize_scan_terminal_filters(
}
+def _market_region_from_tz_offset(tz_offset_seconds: Any) -> Dict[str, str]:
+ tz_offset = _safe_int(tz_offset_seconds, 0)
+ if tz_offset <= -7200:
+ return {
+ "key": "americas",
+ "label_en": "Americas",
+ "label_zh": "美洲",
+ }
+ if tz_offset >= 14400:
+ return {
+ "key": "asia_pacific",
+ "label_en": "Asia-Pacific",
+ "label_zh": "亚太",
+ }
+ return {
+ "key": "europe_africa",
+ "label_en": "Europe / Africa",
+ "label_zh": "欧洲 / 非洲",
+ }
+
+
def _scan_terminal_cache_key(filters: Dict[str, Any]) -> str:
normalized = _normalize_scan_terminal_filters(filters)
return json.dumps(normalized, ensure_ascii=True, sort_keys=True)
@@ -810,12 +831,19 @@ def _build_terminal_row(
final_score = _safe_float(row.get("final_score"))
volume = _safe_float(row.get("volume")) or 0.0
primary_signal = scan.get("primary_signal") or {}
+ city_meta = CITIES.get(city) or {}
+ tz_offset = _safe_int(city_meta.get("tz"), 0)
+ market_region = _market_region_from_tz_offset(tz_offset)
return {
**row,
"id": str(row.get("id") or f"{city}|{selected_date}|{market_slug}|{side}"),
"city": city,
"city_display_name": display_name,
+ "trading_region": market_region["key"],
+ "trading_region_label": market_region["label_en"],
+ "trading_region_label_zh": market_region["label_zh"],
+ "tz_offset_seconds": tz_offset,
"selected_date": selected_date or None,
"local_date": data.get("local_date"),
"local_time": data.get("local_time"),
@@ -1004,6 +1032,16 @@ def _build_scan_terminal_payload_uncached(
}
)
+ if timed_out and not ranked_rows:
+ success_payload = cached_entry.get("success_payload")
+ if isinstance(success_payload, dict) and success_payload.get("rows"):
+ return _build_stale_scan_terminal_payload(
+ filters=filters,
+ success_payload=success_payload,
+ error_message=timeout_message or "市场扫描快照正在刷新中",
+ failed_at=cached_entry.get("last_failed_at"),
+ )
+
unique_market_volume: Dict[str, float] = {}
for row in primary_rows:
market_key = str(row.get("market_key") or row.get("id") or "").strip()