Group scan opportunities by city
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user