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; -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-table-body::-webkit-scrollbar),
.root :global(.scan-calendar-view::-webkit-scrollbar), .root :global(.scan-calendar-view::-webkit-scrollbar),
.root :global(.scan-detail-panel::-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-topbar-tab.active),
.root :global(.scan-terminal.light .scan-hero h1), .root :global(.scan-terminal.light .scan-hero h1),
.root :global(.scan-terminal.light .scan-city-name), .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-detail-city-name),
.root :global(.scan-terminal.light .scan-empty-title), .root :global(.scan-terminal.light .scan-empty-title),
.root :global(.scan-terminal.light .scan-detail-section-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-topbar-time),
.root :global(.scan-terminal.light .scan-hero p), .root :global(.scan-terminal.light .scan-hero p),
.root :global(.scan-terminal.light .scan-city-sub), .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-time-remaining),
.root :global(.scan-terminal.light .scan-detail-city-sub), .root :global(.scan-terminal.light .scan-detail-city-sub),
.root :global(.scan-terminal.light .scan-detail-volume-caption), .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-summary-card),
.root :global(.scan-terminal.light .scan-settings-card), .root :global(.scan-terminal.light .scan-settings-card),
.root :global(.scan-terminal.light .scan-distribution-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) { .root :global(.scan-terminal.light .scan-trade-card) {
border-color: rgba(35, 72, 118, 0.12); border-color: rgba(35, 72, 118, 0.12);
background: rgba(255, 255, 255, 0.72); 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)); 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-locale-switch),
.root :global(.scan-terminal.light .scan-ghost-button), .root :global(.scan-terminal.light .scan-ghost-button),
.root :global(.scan-terminal.light .scan-theme-button), .root :global(.scan-terminal.light .scan-theme-button),
@@ -10570,6 +10742,23 @@
display: none; 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) { .root :global(.scan-trade-cards) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -10594,7 +10783,11 @@
align-items: flex-start; align-items: flex-start;
} }
.root :global(.scan-distribution-preview) { .root :global(.scan-opportunity-groups) {
grid-template-columns: repeat(2, 1fr); 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)}%`; 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) { function formatWindowMinutes(value: number | null | undefined, locale: string) {
if (value == null || !Number.isFinite(Number(value))) return "--"; if (value == null || !Number.isFinite(Number(value))) return "--";
const minutes = Math.max(0, Math.round(Number(value))); const minutes = Math.max(0, Math.round(Number(value)));
@@ -118,20 +110,23 @@ export function getWindowPhaseMeta(
}; };
} }
function ProbabilityPreview({ function formatQuoteCents(value?: number | null) {
row, if (value == null || Number.isNaN(Number(value))) return "--";
locale, const cents = Number(value) * 100;
}: { const text =
row: ScanOpportunityRow; cents < 1 || cents >= 99 || Math.abs(cents - Math.round(cents)) >= 0.05
locale: string; ? 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) const preview = Array.isArray(row.distribution_preview)
? row.distribution_preview.filter( ? row.distribution_preview.filter(
(item): item is DistributionPreviewPoint => (item): item is DistributionPreviewPoint =>
Boolean(item && (item.label || item.value != null)), Boolean(item && (item.label || item.value != null)),
) )
: []; : [];
const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
if (!preview.length) { if (!preview.length) {
const targetBase = const targetBase =
@@ -151,7 +146,11 @@ function ProbabilityPreview({
highlighted: true, highlighted: true,
}); });
} }
return preview;
}
function getEmosPeak(row: ScanOpportunityRow, tempSymbol?: string | null) {
const preview = getDistributionPreview(row, tempSymbol);
const peak = const peak =
preview.reduce<DistributionPreviewPoint | null>((best, item) => { preview.reduce<DistributionPreviewPoint | null>((best, item) => {
const probability = Number(item.model_probability ?? -1); const probability = Number(item.model_probability ?? -1);
@@ -159,53 +158,77 @@ function ProbabilityPreview({
return probability > bestProbability ? item : best; return probability > bestProbability ? item : best;
}, null) || preview.find((item) => item.highlighted) || preview[0]; }, null) || preview.find((item) => item.highlighted) || preview[0];
const peakValue = peak?.value ?? row.peak_value ?? null; const peakValue = peak?.value ?? row.peak_value ?? null;
const peakLabel = const peakLabel = normalizeTemperatureLabel(peak?.label, tempSymbol) ||
peakValue != null (peakValue != null ? formatTemperatureValue(Number(peakValue), tempSymbol) : "--");
? formatTemperatureValue(Number(peakValue), tempSymbol)
: normalizeTemperatureLabel(peak?.label, tempSymbol) || "--";
const peakProbability = const peakProbability =
peak?.model_probability != null peak?.model_probability != null
? Number(peak.model_probability) * 100 ? Number(peak.model_probability) * 100
: row.peak_probability != null : row.peak_probability != null
? Number(row.peak_probability) * 100 ? Number(row.peak_probability) * 100
: null; : null;
const debLabel = return { label: peakLabel, probability: peakProbability };
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 ( type OpportunityGroup = {
<div className="scan-distribution-preview"> key: string;
<div className="scan-model-compare"> cityName: string;
<div className="scan-model-primary"> date?: string | null;
<span>{locale === "en-US" ? "EMOS peak" : "EMOS峰值"}</span> tempSymbol?: string | null;
<strong>{peakLabel}</strong> debLabel: string;
<em>{formatPercent(peakProbability)}</em> peakLabel: string;
</div> peakProbability?: number | null;
<div className="scan-model-rows"> phaseMeta: PhaseMeta;
<span> localTime?: string | null;
<b>DEB</b> remainingMinutes?: number | null;
<strong>{debLabel}</strong> rows: ScanOpportunityRow[];
<em>{delta == null ? "--" : formatSignedTemperatureDelta(delta, tempSymbol)}</em> };
</span>
<span> function buildOpportunityGroups(rows: ScanOpportunityRow[], locale: string): OpportunityGroup[] {
<b>{locale === "en-US" ? "Target" : "目标"}</b> const groups = new Map<string, OpportunityGroup>();
<strong>{targetLabel}</strong> for (const row of rows) {
<em>EMOS {formatPercent(targetProbability)}</em> const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
</span> const cityName = getLocalizedCityName(
</div> row.city,
</div> row.city_display_name || row.display_name || row.city,
</div> 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({ export const OpportunityTable = React.memo(function OpportunityTable({
@@ -230,6 +253,10 @@ export const OpportunityTable = React.memo(function OpportunityTable({
const hasRows = rows.length > 0; const hasRows = rows.length > 0;
const scanInProgress = const scanInProgress =
loading || status === "partial" || status === "scanning"; loading || status === "partial" || status === "scanning";
const groups = React.useMemo(
() => buildOpportunityGroups(rows, locale),
[rows, locale],
);
if (!hasRows) { if (!hasRows) {
const title = 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> <span>{staleReason || (isEn ? "Latest refresh failed, fallback to the last successful scan." : "最新刷新失败,已回退到上次成功扫描结果。")}</span>
</div> </div>
) : null} ) : null}
<div className="scan-table-header"> <div className="scan-table-body scan-opportunity-groups">
<span>{isEn ? "City / Market" : "城市 / 市场"}</span> {groups.map((group) => (
<span>{isEn ? "Local Time / Phase" : "当前时间 / 阶段"}</span> <section key={group.key} className="scan-opportunity-group">
<span>{isEn ? "EMOS / DEB" : "EMOS / DEB"}</span> <div className="scan-opportunity-group-head">
<span>{isEn ? "Quote / Model" : "买价 / 模型"}</span> <div className="scan-opportunity-city">
<span>{isEn ? "Edge" : "边际优势"}</span> <strong>{group.cityName}</strong>
</div> <span>
DEB {group.debLabel} · EMOS peak {group.peakLabel}
<div className="scan-table-body"> {group.peakProbability != null ? ` · ${formatPercent(group.peakProbability)}` : ""}
{rows.map((row) => { </span>
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> </div>
<div className="scan-opportunity-phase">
<div className="scan-time-cell"> <span>{group.localTime || "--"}</span>
<div className="scan-time-main">{row.local_time || "--"}</div> <b className={`scan-phase-badge ${group.phaseMeta.tone}`}>
<div className={`scan-phase-badge ${phaseMeta.tone}`}> {group.phaseMeta.label}
{phaseMeta.label} </b>
</div> <em>{formatWindowMinutes(group.remainingMinutes, locale)}</em>
<div className="scan-time-remaining">
{formatWindowMinutes(row.remaining_window_minutes, locale)}
</div>
</div> </div>
</div>
<ProbabilityPreview row={row} locale={locale} /> <div className="scan-opportunity-items">
{group.rows.map((row, rowIndex) => {
<div className="scan-trade-cell"> const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
<div className={`scan-trade-main ${row.side === "no" ? "sell" : "buy"}`}> const selected = selectedRowId === row.id;
{formatAction(row, locale, tempSymbol)} const side = String(row.side || "").toLowerCase();
</div> const modelProbability =
<div className="scan-trade-sub"> row.model_probability != null
{isEn ? "Buy" : "买价"} {row.ask != null ? `${Math.round(row.ask * 100)}¢` : "--"} ·{" "} ? row.model_probability * 100
EMOS {formatPercent(row.model_probability != null ? row.model_probability * 100 : null)} : row.model_event_probability != null
</div> ? row.model_event_probability * 100
<div className="scan-trade-note"> : null;
{normalizeTemperatureLabel(row.target_label, tempSymbol) || const priceLabel = side === "no" ? "NO" : isEn ? "Market" : "市场";
row.market_direction || const edgePositive = Number(row.edge_percent || 0) >= 0;
"--"} return (
</div> <button
</div> key={row.id}
type="button"
<div className={`scan-edge-cell ${Number(row.edge_percent || 0) >= 0 ? "positive" : "negative"}`}> className={`scan-opportunity-item ${selected ? "selected" : ""}`}
{formatPercent(row.edge_percent, true)} onClick={() => onSelectRow?.(row)}
</div> >
</button> <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>
</div> </div>
); );
+17 -12
View File
@@ -372,19 +372,24 @@ def _scan_city_terminal_rows(
) )
scan = payload.get("market_scan") or {} scan = payload.get("market_scan") or {}
candidate_total += int(scan.get("candidate_count") or 0) candidate_total += int(scan.get("candidate_count") or 0)
primary_signal = scan.get("primary_signal") raw_rows = scan.get("scan_rows")
if not isinstance(primary_signal, dict) or not primary_signal: 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 continue
row = _build_terminal_row( for raw_row in raw_rows:
city=city, if not isinstance(raw_row, dict) or not raw_row:
data=data, continue
scan=scan, row = _build_terminal_row(
row=primary_signal, city=city,
) data=data,
rows.append(row) scan=scan,
score = _safe_float(row.get("final_score")) row=raw_row,
if score is not None: )
primary_scores.append(score) 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 { return {
"city": city, "city": city,