From 8f7ffae83d8f7fb1400257b28ebdd1255d6fb288 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 24 Apr 2026 14:41:33 +0800 Subject: [PATCH] Group scan opportunities by city --- .../components/dashboard/Dashboard.module.css | 197 ++++++++++++- .../components/dashboard/OpportunityTable.tsx | 259 ++++++++++-------- web/scan_terminal_service.py | 29 +- 3 files changed, 351 insertions(+), 134 deletions(-) diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 1daa3eaa..f2dc7687 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -9594,6 +9594,155 @@ -ms-overflow-style: none; } +.root :global(.scan-opportunity-groups) { + display: flex; + flex-direction: column; + gap: 14px; + padding: 14px 18px 18px; +} + +.root :global(.scan-opportunity-group) { + border: 1px solid rgba(90, 123, 166, 0.14); + border-radius: 16px; + overflow: hidden; + background: linear-gradient(180deg, rgba(10, 21, 36, 0.94), rgba(8, 17, 29, 0.94)); +} + +.root :global(.scan-opportunity-group:has(.scan-opportunity-item.selected)) { + border-color: rgba(23, 217, 139, 0.42); + box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.18); +} + +.root :global(.scan-opportunity-group-head) { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; + padding: 16px 18px; + border-bottom: 1px solid rgba(90, 123, 166, 0.1); + background: rgba(13, 28, 48, 0.62); +} + +.root :global(.scan-opportunity-city) { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.root :global(.scan-opportunity-city strong) { + color: #eef7ff; + font-size: 18px; + font-weight: 900; +} + +.root :global(.scan-opportunity-city span) { + overflow: hidden; + color: #9fb4cf; + font-size: 14px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.root :global(.scan-opportunity-phase) { + display: flex; + gap: 10px; + align-items: center; + color: #9fb4cf; + font-size: 13px; + white-space: nowrap; +} + +.root :global(.scan-opportunity-phase > span) { + color: #eef7ff; + font-weight: 800; +} + +.root :global(.scan-opportunity-phase em) { + color: #9fb4cf; + font-style: normal; +} + +.root :global(.scan-opportunity-items) { + display: flex; + flex-direction: column; +} + +.root :global(.scan-opportunity-item) { + display: grid; + grid-template-columns: 24px minmax(190px, 1.1fr) minmax(92px, 0.45fr) minmax(92px, 0.45fr) minmax(90px, 0.38fr); + gap: 12px; + align-items: center; + width: 100%; + min-height: 48px; + padding: 10px 18px; + border: 0; + border-bottom: 1px solid rgba(90, 123, 166, 0.08); + background: transparent; + color: #b8c9e1; + text-align: left; +} + +.root :global(.scan-opportunity-item:last-child) { + border-bottom: 0; +} + +.root :global(.scan-opportunity-item:hover) { + background: rgba(30, 70, 110, 0.18); +} + +.root :global(.scan-opportunity-item.selected) { + background: rgba(10, 74, 65, 0.38); + box-shadow: inset 3px 0 0 rgba(23, 217, 139, 0.78); +} + +.root :global(.scan-opportunity-branch) { + color: rgba(159, 180, 207, 0.72); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 16px; +} + +.root :global(.scan-opportunity-action) { + overflow: hidden; + font-size: 15px; + font-weight: 900; + text-overflow: ellipsis; + white-space: nowrap; +} + +.root :global(.scan-opportunity-action.buy) { + color: #22e895; +} + +.root :global(.scan-opportunity-action.sell) { + color: #ff7474; +} + +.root :global(.scan-opportunity-item span:not(.scan-opportunity-branch)) { + overflow: hidden; + font-size: 14px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.root :global(.scan-opportunity-item em) { + justify-self: end; + font-size: 15px; + font-style: normal; + font-weight: 900; + white-space: nowrap; +} + +.root :global(.scan-opportunity-item em.positive) { + color: #1de28f; +} + +.root :global(.scan-opportunity-item em.negative) { + color: #ff6868; +} + .root :global(.scan-table-body::-webkit-scrollbar), .root :global(.scan-calendar-view::-webkit-scrollbar), .root :global(.scan-detail-panel::-webkit-scrollbar) { @@ -10414,6 +10563,7 @@ .root :global(.scan-terminal.light .scan-topbar-tab.active), .root :global(.scan-terminal.light .scan-hero h1), .root :global(.scan-terminal.light .scan-city-name), +.root :global(.scan-terminal.light .scan-opportunity-city strong), .root :global(.scan-terminal.light .scan-detail-city-name), .root :global(.scan-terminal.light .scan-empty-title), .root :global(.scan-terminal.light .scan-detail-section-title), @@ -10433,6 +10583,9 @@ .root :global(.scan-terminal.light .scan-topbar-time), .root :global(.scan-terminal.light .scan-hero p), .root :global(.scan-terminal.light .scan-city-sub), +.root :global(.scan-terminal.light .scan-opportunity-city span), +.root :global(.scan-terminal.light .scan-opportunity-phase), +.root :global(.scan-terminal.light .scan-opportunity-phase em), .root :global(.scan-terminal.light .scan-time-remaining), .root :global(.scan-terminal.light .scan-detail-city-sub), .root :global(.scan-terminal.light .scan-detail-volume-caption), @@ -10468,6 +10621,7 @@ .root :global(.scan-terminal.light .scan-summary-card), .root :global(.scan-terminal.light .scan-settings-card), .root :global(.scan-terminal.light .scan-distribution-card), +.root :global(.scan-terminal.light .scan-opportunity-group), .root :global(.scan-terminal.light .scan-trade-card) { border-color: rgba(35, 72, 118, 0.12); background: rgba(255, 255, 255, 0.72); @@ -10489,6 +10643,24 @@ background: linear-gradient(180deg, rgba(248, 252, 255, 0.96), rgba(238, 247, 255, 0.96)); } +.root :global(.scan-terminal.light .scan-opportunity-group-head) { + background: rgba(246, 250, 255, 0.78); + border-bottom-color: rgba(35, 72, 118, 0.1); +} + +.root :global(.scan-terminal.light .scan-opportunity-item) { + color: #405977; + border-bottom-color: rgba(35, 72, 118, 0.08); +} + +.root :global(.scan-terminal.light .scan-opportunity-item:hover) { + background: rgba(230, 242, 255, 0.76); +} + +.root :global(.scan-terminal.light .scan-opportunity-item.selected) { + background: rgba(222, 250, 238, 0.82); +} + .root :global(.scan-terminal.light .scan-locale-switch), .root :global(.scan-terminal.light .scan-ghost-button), .root :global(.scan-terminal.light .scan-theme-button), @@ -10570,6 +10742,23 @@ display: none; } + .root :global(.scan-opportunity-group-head), + .root :global(.scan-opportunity-item) { + grid-template-columns: 1fr; + } + + .root :global(.scan-opportunity-phase) { + flex-wrap: wrap; + } + + .root :global(.scan-opportunity-branch) { + display: none; + } + + .root :global(.scan-opportunity-item em) { + justify-self: start; + } + .root :global(.scan-trade-cards) { grid-template-columns: 1fr; } @@ -10594,7 +10783,11 @@ align-items: flex-start; } - .root :global(.scan-distribution-preview) { - grid-template-columns: repeat(2, 1fr); + .root :global(.scan-opportunity-groups) { + padding: 12px; + } + + .root :global(.scan-opportunity-city span) { + white-space: normal; } } diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx index 6ee94922..a507347f 100644 --- a/frontend/components/dashboard/OpportunityTable.tsx +++ b/frontend/components/dashboard/OpportunityTable.tsx @@ -24,14 +24,6 @@ function formatPercent(value?: number | null, signed = false) { return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`; } -function formatSignedTemperatureDelta(value?: number | null, tempSymbol?: string | null) { - if (value == null || Number.isNaN(Number(value))) return "--"; - const numeric = Number(value); - if (Math.abs(numeric) < 0.05) return "0"; - const absValue = formatTemperatureValue(Math.abs(numeric), tempSymbol); - return `${numeric > 0 ? "+" : "-"}${absValue}`; -} - function formatWindowMinutes(value: number | null | undefined, locale: string) { if (value == null || !Number.isFinite(Number(value))) return "--"; const minutes = Math.max(0, Math.round(Number(value))); @@ -118,20 +110,23 @@ export function getWindowPhaseMeta( }; } -function ProbabilityPreview({ - row, - locale, -}: { - row: ScanOpportunityRow; - locale: string; -}) { +function formatQuoteCents(value?: number | null) { + if (value == null || Number.isNaN(Number(value))) return "--"; + const cents = Number(value) * 100; + const text = + cents < 1 || cents >= 99 || Math.abs(cents - Math.round(cents)) >= 0.05 + ? cents.toFixed(1) + : Math.round(cents).toFixed(0); + return `${text.replace(/\.0$/, "")}¢`; +} + +function getDistributionPreview(row: ScanOpportunityRow, tempSymbol?: string | null) { const preview = Array.isArray(row.distribution_preview) ? row.distribution_preview.filter( (item): item is DistributionPreviewPoint => Boolean(item && (item.label || item.value != null)), ) : []; - const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol); if (!preview.length) { const targetBase = @@ -151,7 +146,11 @@ function ProbabilityPreview({ highlighted: true, }); } + return preview; +} +function getEmosPeak(row: ScanOpportunityRow, tempSymbol?: string | null) { + const preview = getDistributionPreview(row, tempSymbol); const peak = preview.reduce((best, item) => { const probability = Number(item.model_probability ?? -1); @@ -159,53 +158,77 @@ function ProbabilityPreview({ return probability > bestProbability ? item : best; }, null) || preview.find((item) => item.highlighted) || preview[0]; const peakValue = peak?.value ?? row.peak_value ?? null; - const peakLabel = - peakValue != null - ? formatTemperatureValue(Number(peakValue), tempSymbol) - : normalizeTemperatureLabel(peak?.label, tempSymbol) || "--"; + const peakLabel = normalizeTemperatureLabel(peak?.label, tempSymbol) || + (peakValue != null ? formatTemperatureValue(Number(peakValue), tempSymbol) : "--"); const peakProbability = peak?.model_probability != null ? Number(peak.model_probability) * 100 : row.peak_probability != null ? Number(row.peak_probability) * 100 : null; - const debLabel = - row.deb_prediction != null - ? formatTemperatureValue(Number(row.deb_prediction), tempSymbol) - : "--"; - const delta = - peakValue != null && row.deb_prediction != null - ? Number(peakValue) - Number(row.deb_prediction) - : null; - const targetLabel = - normalizeTemperatureLabel(row.target_label, tempSymbol) || - (row.target_value != null ? formatTemperatureValue(Number(row.target_value), tempSymbol) : "--"); - const targetProbability = - row.model_event_probability != null ? Number(row.model_event_probability) * 100 : null; + return { label: peakLabel, probability: peakProbability }; +} - return ( -
-
-
- {locale === "en-US" ? "EMOS peak" : "EMOS峰值"} - {peakLabel} - {formatPercent(peakProbability)} -
-
- - DEB - {debLabel} - {delta == null ? "--" : formatSignedTemperatureDelta(delta, tempSymbol)} - - - {locale === "en-US" ? "Target" : "目标"} - {targetLabel} - EMOS {formatPercent(targetProbability)} - -
-
-
- ); +type OpportunityGroup = { + key: string; + cityName: string; + date?: string | null; + tempSymbol?: string | null; + debLabel: string; + peakLabel: string; + peakProbability?: number | null; + phaseMeta: PhaseMeta; + localTime?: string | null; + remainingMinutes?: number | null; + rows: ScanOpportunityRow[]; +}; + +function buildOpportunityGroups(rows: ScanOpportunityRow[], locale: string): OpportunityGroup[] { + const groups = new Map(); + for (const row of rows) { + const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol); + const cityName = getLocalizedCityName( + row.city, + row.city_display_name || row.display_name || row.city, + locale, + ); + const date = row.selected_date || row.local_date || ""; + const key = `${row.city || cityName}|${date}`; + const peak = getEmosPeak(row, tempSymbol); + const existing = groups.get(key); + if (!existing) { + groups.set(key, { + key, + cityName, + date, + tempSymbol, + debLabel: + row.deb_prediction != null + ? formatTemperatureValue(Number(row.deb_prediction), tempSymbol, { digits: 1 }) + : "--", + peakLabel: peak.label, + peakProbability: peak.probability, + phaseMeta: getWindowPhaseMeta(row, locale), + localTime: row.local_time, + remainingMinutes: row.remaining_window_minutes, + rows: [row], + }); + continue; + } + existing.rows.push(row); + if ((peak.probability ?? -1) > (existing.peakProbability ?? -1)) { + existing.peakLabel = peak.label; + existing.peakProbability = peak.probability; + } + } + return Array.from(groups.values()).map((group) => ({ + ...group, + rows: [...group.rows].sort( + (a, b) => + Number(b.edge_percent ?? -Infinity) - Number(a.edge_percent ?? -Infinity) || + Number(b.final_score ?? -Infinity) - Number(a.final_score ?? -Infinity), + ), + })); } export const OpportunityTable = React.memo(function OpportunityTable({ @@ -230,6 +253,10 @@ export const OpportunityTable = React.memo(function OpportunityTable({ const hasRows = rows.length > 0; const scanInProgress = loading || status === "partial" || status === "scanning"; + const groups = React.useMemo( + () => buildOpportunityGroups(rows, locale), + [rows, locale], + ); if (!hasRows) { const title = @@ -272,73 +299,65 @@ export const OpportunityTable = React.memo(function OpportunityTable({ {staleReason || (isEn ? "Latest refresh failed, fallback to the last successful scan." : "最新刷新失败,已回退到上次成功扫描结果。")} ) : null} -
- {isEn ? "City / Market" : "城市 / 市场"} - {isEn ? "Local Time / Phase" : "当前时间 / 阶段"} - {isEn ? "EMOS / DEB" : "EMOS / DEB"} - {isEn ? "Quote / Model" : "买价 / 模型"} - {isEn ? "Edge" : "边际优势"} -
- -
- {rows.map((row) => { - const phaseMeta = getWindowPhaseMeta(row, locale); - const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol); - const localizedCityName = getLocalizedCityName( - row.city, - row.city_display_name || row.display_name || row.city, - locale, - ); - const selected = selectedRowId === row.id; - return ( - - ); - })} +
+ {group.rows.map((row, rowIndex) => { + const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol); + const selected = selectedRowId === row.id; + const side = String(row.side || "").toLowerCase(); + const modelProbability = + row.model_probability != null + ? row.model_probability * 100 + : row.model_event_probability != null + ? row.model_event_probability * 100 + : null; + const priceLabel = side === "no" ? "NO" : isEn ? "Market" : "市场"; + const edgePositive = Number(row.edge_percent || 0) >= 0; + return ( + + ); + })} +
+ + ))}
); diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 154b752a..e1db2a77 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -372,19 +372,24 @@ def _scan_city_terminal_rows( ) scan = payload.get("market_scan") or {} candidate_total += int(scan.get("candidate_count") or 0) - primary_signal = scan.get("primary_signal") - if not isinstance(primary_signal, dict) or not primary_signal: + raw_rows = scan.get("scan_rows") + if not isinstance(raw_rows, list) or not raw_rows: + raw_rows = [scan.get("primary_signal")] if isinstance(scan.get("primary_signal"), dict) else [] + if not raw_rows: continue - row = _build_terminal_row( - city=city, - data=data, - scan=scan, - row=primary_signal, - ) - rows.append(row) - score = _safe_float(row.get("final_score")) - if score is not None: - primary_scores.append(score) + for raw_row in raw_rows: + if not isinstance(raw_row, dict) or not raw_row: + continue + row = _build_terminal_row( + city=city, + data=data, + scan=scan, + row=raw_row, + ) + rows.append(row) + score = _safe_float(row.get("final_score")) + if score is not None and row.get("is_primary_signal"): + primary_scores.append(score) return { "city": city,