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()