Group scan opportunities by city

This commit is contained in:
2569718930@qq.com
2026-04-24 14:41:33 +08:00
parent 3c50a72f56
commit 8f7ffae83d
3 changed files with 351 additions and 134 deletions
@@ -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;
}
}
+139 -120
View File
@@ -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<DistributionPreviewPoint | null>((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 (
<div className="scan-distribution-preview">
<div className="scan-model-compare">
<div className="scan-model-primary">
<span>{locale === "en-US" ? "EMOS peak" : "EMOS峰值"}</span>
<strong>{peakLabel}</strong>
<em>{formatPercent(peakProbability)}</em>
</div>
<div className="scan-model-rows">
<span>
<b>DEB</b>
<strong>{debLabel}</strong>
<em>{delta == null ? "--" : formatSignedTemperatureDelta(delta, tempSymbol)}</em>
</span>
<span>
<b>{locale === "en-US" ? "Target" : "目标"}</b>
<strong>{targetLabel}</strong>
<em>EMOS {formatPercent(targetProbability)}</em>
</span>
</div>
</div>
</div>
);
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<string, OpportunityGroup>();
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({
<span>{staleReason || (isEn ? "Latest refresh failed, fallback to the last successful scan." : "最新刷新失败,已回退到上次成功扫描结果。")}</span>
</div>
) : null}
<div className="scan-table-header">
<span>{isEn ? "City / Market" : "城市 / 市场"}</span>
<span>{isEn ? "Local Time / Phase" : "当前时间 / 阶段"}</span>
<span>{isEn ? "EMOS / DEB" : "EMOS / DEB"}</span>
<span>{isEn ? "Quote / Model" : "买价 / 模型"}</span>
<span>{isEn ? "Edge" : "边际优势"}</span>
</div>
<div className="scan-table-body">
{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 (
<button
key={row.id}
type="button"
className={`scan-table-row ${selected ? "selected" : ""}`}
onClick={() => onSelectRow?.(row)}
>
<div className="scan-city-cell">
<div className="scan-city-copy">
<div className="scan-city-name">{localizedCityName}</div>
<div className="scan-city-sub">
{row.market_question || row.target_label || "--"}
</div>
</div>
<div className="scan-table-body scan-opportunity-groups">
{groups.map((group) => (
<section key={group.key} className="scan-opportunity-group">
<div className="scan-opportunity-group-head">
<div className="scan-opportunity-city">
<strong>{group.cityName}</strong>
<span>
DEB {group.debLabel} · EMOS peak {group.peakLabel}
{group.peakProbability != null ? ` · ${formatPercent(group.peakProbability)}` : ""}
</span>
</div>
<div className="scan-time-cell">
<div className="scan-time-main">{row.local_time || "--"}</div>
<div className={`scan-phase-badge ${phaseMeta.tone}`}>
{phaseMeta.label}
</div>
<div className="scan-time-remaining">
{formatWindowMinutes(row.remaining_window_minutes, locale)}
</div>
<div className="scan-opportunity-phase">
<span>{group.localTime || "--"}</span>
<b className={`scan-phase-badge ${group.phaseMeta.tone}`}>
{group.phaseMeta.label}
</b>
<em>{formatWindowMinutes(group.remainingMinutes, locale)}</em>
</div>
</div>
<ProbabilityPreview row={row} locale={locale} />
<div className="scan-trade-cell">
<div className={`scan-trade-main ${row.side === "no" ? "sell" : "buy"}`}>
{formatAction(row, locale, tempSymbol)}
</div>
<div className="scan-trade-sub">
{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) ||
row.market_direction ||
"--"}
</div>
</div>
<div className={`scan-edge-cell ${Number(row.edge_percent || 0) >= 0 ? "positive" : "negative"}`}>
{formatPercent(row.edge_percent, true)}
</div>
</button>
);
})}
<div className="scan-opportunity-items">
{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 (
<button
key={row.id}
type="button"
className={`scan-opportunity-item ${selected ? "selected" : ""}`}
onClick={() => onSelectRow?.(row)}
>
<span className="scan-opportunity-branch">
{rowIndex === group.rows.length - 1 ? "└" : "├"}
</span>
<strong className={`scan-opportunity-action ${side === "no" ? "sell" : "buy"}`}>
{formatAction(row, locale, tempSymbol)}
</strong>
<span>EMOS {formatPercent(modelProbability)}</span>
<span>
{priceLabel} {formatQuoteCents(row.ask)}
</span>
<em className={edgePositive ? "positive" : "negative"}>
edge {formatPercent(row.edge_percent, true)}
</em>
</button>
);
})}
</div>
</section>
))}
</div>
</div>
);
+17 -12
View File
@@ -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,