2026-04-23 22:48:57 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2026-04-25 03:26:28 +08:00
|
|
|
|
import { BarChart3, ChevronDown, ChevronUp } from "lucide-react";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
import React from "react";
|
2026-04-23 22:48:57 +08:00
|
|
|
|
import { useI18n } from "@/hooks/useI18n";
|
2026-04-24 00:34:09 +08:00
|
|
|
|
import type {
|
2026-04-25 01:41:45 +08:00
|
|
|
|
CityDetail,
|
2026-04-24 00:34:09 +08:00
|
|
|
|
DistributionPreviewPoint,
|
|
|
|
|
|
ScanOpportunityRow,
|
|
|
|
|
|
} from "@/lib/dashboard-types";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
2026-04-24 02:14:20 +08:00
|
|
|
|
import {
|
|
|
|
|
|
formatTemperatureValue,
|
2026-04-25 01:41:45 +08:00
|
|
|
|
getModelView,
|
|
|
|
|
|
getProbabilityView,
|
2026-04-24 02:14:20 +08:00
|
|
|
|
normalizeTemperatureLabel,
|
|
|
|
|
|
normalizeTemperatureSymbol,
|
|
|
|
|
|
} from "@/lib/dashboard-utils";
|
2026-04-23 22:48:57 +08:00
|
|
|
|
|
2026-04-24 00:34:09 +08:00
|
|
|
|
type PhaseMeta = {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
tone: "green" | "amber" | "blue" | "red";
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-23 23:49:52 +08:00
|
|
|
|
function formatPercent(value?: number | null, signed = false) {
|
|
|
|
|
|
if (value == null || Number.isNaN(Number(value))) return "--";
|
|
|
|
|
|
const numeric = Number(value);
|
2026-04-24 00:13:11 +08:00
|
|
|
|
return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
|
2026-04-23 23:49:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 01:41:45 +08:00
|
|
|
|
function normalizeProbability(value?: number | null) {
|
|
|
|
|
|
if (value == null) return null;
|
|
|
|
|
|
const numeric = Number(value);
|
|
|
|
|
|
if (!Number.isFinite(numeric)) return null;
|
|
|
|
|
|
return Math.max(0, Math.min(1, numeric > 1 ? numeric / 100 : numeric));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 00:13:11 +08:00
|
|
|
|
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)));
|
|
|
|
|
|
const hours = Math.floor(minutes / 60);
|
|
|
|
|
|
const remains = minutes % 60;
|
|
|
|
|
|
if (locale === "en-US") {
|
|
|
|
|
|
if (hours <= 0) return `${remains}m left`;
|
|
|
|
|
|
return `${hours}h ${remains}m left`;
|
2026-04-23 23:49:52 +08:00
|
|
|
|
}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
if (hours <= 0) return `剩余 ${remains} 分钟`;
|
|
|
|
|
|
return `剩余 ${hours}h ${remains}m`;
|
2026-04-23 23:49:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 03:30:55 +08:00
|
|
|
|
function formatAction(
|
|
|
|
|
|
row: ScanOpportunityRow,
|
|
|
|
|
|
locale: string,
|
|
|
|
|
|
tempSymbol?: string | null,
|
|
|
|
|
|
) {
|
|
|
|
|
|
const formattedTarget = normalizeTemperatureLabel(row.target_label, tempSymbol);
|
|
|
|
|
|
if (row.action) {
|
2026-04-25 03:59:02 +08:00
|
|
|
|
const normalizedAction = row.target_label
|
2026-04-24 03:30:55 +08:00
|
|
|
|
? row.action.replace(String(row.target_label), formattedTarget || String(row.target_label))
|
|
|
|
|
|
: row.action;
|
2026-04-25 03:59:02 +08:00
|
|
|
|
return normalizeTemperatureLabel(normalizedAction, tempSymbol);
|
2026-04-24 03:30:55 +08:00
|
|
|
|
}
|
2026-04-23 23:49:52 +08:00
|
|
|
|
if (row.side === "yes") {
|
2026-04-24 03:30:55 +08:00
|
|
|
|
return `${locale === "en-US" ? "Buy Yes" : "买入 Yes"} ${formattedTarget || ""}`.trim();
|
2026-04-23 23:49:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (row.side === "no") {
|
2026-04-24 03:30:55 +08:00
|
|
|
|
return `${locale === "en-US" ? "Buy No" : "买入 No"} ${formattedTarget || ""}`.trim();
|
2026-04-23 23:49:52 +08:00
|
|
|
|
}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
return "--";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 00:34:09 +08:00
|
|
|
|
export function getWindowPhaseMeta(
|
|
|
|
|
|
row: Pick<ScanOpportunityRow, "window_phase" | "trend_alignment">,
|
2026-04-24 00:13:11 +08:00
|
|
|
|
locale: string,
|
2026-04-24 00:34:09 +08:00
|
|
|
|
): PhaseMeta {
|
2026-04-24 00:13:11 +08:00
|
|
|
|
const mode = String(row.window_phase || "").toLowerCase();
|
2026-04-24 13:09:31 +08:00
|
|
|
|
if (mode === "city_snapshot") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? "City Snapshot" : "城市概况",
|
|
|
|
|
|
tone: "blue",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-04-24 00:34:09 +08:00
|
|
|
|
if (mode === "active_peak") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? "Peak Window" : "峰值窗口",
|
|
|
|
|
|
tone: "red",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-04-24 04:14:52 +08:00
|
|
|
|
if (mode === "setup_today") {
|
2026-04-24 00:13:11 +08:00
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? "Touch Play" : "触达博弈",
|
|
|
|
|
|
tone: "red",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-04-24 04:14:52 +08:00
|
|
|
|
if (mode === "early_today") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? "Early Today" : "日内早段",
|
|
|
|
|
|
tone: "blue",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
if (mode === "tomorrow" || mode === "week_ahead") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? "Early" : "早期机会",
|
|
|
|
|
|
tone: "blue",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-04-24 00:34:09 +08:00
|
|
|
|
if (mode === "post_peak") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? "Post Peak" : "峰后确认",
|
|
|
|
|
|
tone: "amber",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
if (row.trend_alignment) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? "Trend" : "趋势确认",
|
|
|
|
|
|
tone: "amber",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? "Tradable" : "可交易",
|
|
|
|
|
|
tone: "green",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 14:41:33 +08:00
|
|
|
|
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$/, "")}¢`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 03:59:02 +08:00
|
|
|
|
function formatTradeSide(
|
|
|
|
|
|
row: ScanOpportunityRow,
|
|
|
|
|
|
locale: string,
|
|
|
|
|
|
tempSymbol?: string | null,
|
|
|
|
|
|
) {
|
2026-04-25 03:26:28 +08:00
|
|
|
|
const side = String(row.side || "").toLowerCase();
|
|
|
|
|
|
if (side === "yes") return "BUY YES";
|
|
|
|
|
|
if (side === "no") return "BUY NO";
|
|
|
|
|
|
if (row.action) {
|
2026-04-25 03:59:02 +08:00
|
|
|
|
return normalizeTemperatureLabel(
|
|
|
|
|
|
String(row.action).replace(String(row.target_label || ""), ""),
|
|
|
|
|
|
tempSymbol,
|
|
|
|
|
|
)
|
2026-04-25 03:26:28 +08:00
|
|
|
|
.replace(/\s+/g, " ")
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toUpperCase();
|
|
|
|
|
|
}
|
|
|
|
|
|
return locale === "en-US" ? "WATCH" : "观察";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatThreshold(row: ScanOpportunityRow, tempSymbol?: string | null) {
|
|
|
|
|
|
const targetLabel = normalizeTemperatureLabel(row.target_label, tempSymbol);
|
|
|
|
|
|
if (targetLabel) return targetLabel;
|
|
|
|
|
|
if (row.target_lower != null && row.target_upper != null) {
|
|
|
|
|
|
return `${formatTemperatureValue(Number(row.target_lower), tempSymbol)} ~ ${formatTemperatureValue(Number(row.target_upper), tempSymbol)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (row.target_threshold != null) {
|
|
|
|
|
|
return formatTemperatureValue(Number(row.target_threshold), tempSymbol);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (row.target_value != null) {
|
|
|
|
|
|
return formatTemperatureValue(Number(row.target_value), tempSymbol);
|
|
|
|
|
|
}
|
|
|
|
|
|
return "--";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getOpportunityStrength(edgePercent?: number | null, locale = "zh-CN") {
|
|
|
|
|
|
const edge = Number(edgePercent);
|
|
|
|
|
|
const normalized = Number.isFinite(edge) ? edge : 0;
|
|
|
|
|
|
if (normalized >= 20) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? "Strong" : "强机会",
|
|
|
|
|
|
tone: "strong",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (normalized >= 10) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? "Medium" : "中机会",
|
|
|
|
|
|
tone: "medium",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? "Watch" : "观察",
|
|
|
|
|
|
tone: "watch",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getLocalizedRowText(
|
|
|
|
|
|
row: ScanOpportunityRow,
|
|
|
|
|
|
locale: string,
|
|
|
|
|
|
zh?: string | null,
|
|
|
|
|
|
en?: string | null,
|
|
|
|
|
|
) {
|
|
|
|
|
|
return locale === "en-US" ? en || zh || null : zh || en || null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatModelSources(row: ScanOpportunityRow, tempSymbol?: string | null) {
|
|
|
|
|
|
const sources = row.model_cluster_sources || {};
|
|
|
|
|
|
return Object.entries(sources)
|
|
|
|
|
|
.filter(([, value]) => value != null && Number.isFinite(Number(value)))
|
|
|
|
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
|
|
|
|
.map(([name, value]) => ({
|
|
|
|
|
|
name,
|
|
|
|
|
|
value: formatTemperatureValue(Number(value), tempSymbol, { digits: 1 }),
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getModelSourceSummary(
|
|
|
|
|
|
row: ScanOpportunityRow,
|
|
|
|
|
|
locale: string,
|
|
|
|
|
|
tempSymbol?: string | null,
|
|
|
|
|
|
) {
|
|
|
|
|
|
const sources = formatModelSources(row, tempSymbol);
|
|
|
|
|
|
if (!sources.length) {
|
|
|
|
|
|
return locale === "en-US"
|
|
|
|
|
|
? "model cluster pending"
|
|
|
|
|
|
: "模型集群暂未回传";
|
|
|
|
|
|
}
|
|
|
|
|
|
const shown = sources.map((item) => `${item.name} ${item.value}`).join(" / ");
|
|
|
|
|
|
return locale === "en-US"
|
|
|
|
|
|
? `all models: ${shown}`
|
|
|
|
|
|
: `全部模型:${shown}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getShortAiConclusion(
|
|
|
|
|
|
row: ScanOpportunityRow,
|
|
|
|
|
|
locale: string,
|
|
|
|
|
|
edgePercent?: number | null,
|
|
|
|
|
|
strengthLabel?: string,
|
|
|
|
|
|
) {
|
|
|
|
|
|
const directReason =
|
|
|
|
|
|
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
|
|
|
|
|
|
getLocalizedRowText(
|
|
|
|
|
|
row,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
row.ai_watchlist_reason_zh,
|
|
|
|
|
|
row.ai_watchlist_reason_en,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (directReason) return directReason;
|
|
|
|
|
|
const cityThesis = getLocalizedRowText(
|
|
|
|
|
|
row,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
row.ai_city_thesis_zh,
|
|
|
|
|
|
row.ai_city_thesis_en,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (cityThesis) return cityThesis;
|
|
|
|
|
|
|
|
|
|
|
|
const edgeText = formatPercent(edgePercent, true);
|
|
|
|
|
|
const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
|
|
|
|
|
|
if (locale === "en-US") {
|
|
|
|
|
|
return `${strengthLabel || "Watch"} setup: edge ${edgeText}; V4 should validate against ${modelBasis}.`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${strengthLabel || "观察"}:edge ${edgeText},V4 需结合${modelBasis}确认。`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getRiskHints(
|
|
|
|
|
|
row: ScanOpportunityRow,
|
|
|
|
|
|
locale: string,
|
|
|
|
|
|
modelProbability?: number | null,
|
|
|
|
|
|
) {
|
|
|
|
|
|
const hints: string[] = [];
|
|
|
|
|
|
const spread = Number(row.spread);
|
|
|
|
|
|
if (Number.isFinite(spread) && spread > 0.03) {
|
|
|
|
|
|
hints.push(
|
|
|
|
|
|
locale === "en-US"
|
|
|
|
|
|
? `Wide spread ${formatQuoteCents(spread)} may distort executable edge.`
|
|
|
|
|
|
: `盘口价差 ${formatQuoteCents(spread)} 偏宽,可能扭曲可执行 edge。`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
const quoteAgeSeconds =
|
|
|
|
|
|
row.quote_age_ms != null && Number.isFinite(Number(row.quote_age_ms))
|
|
|
|
|
|
? Math.round(Number(row.quote_age_ms) / 1000)
|
|
|
|
|
|
: null;
|
|
|
|
|
|
if (quoteAgeSeconds != null && quoteAgeSeconds > 60) {
|
|
|
|
|
|
hints.push(
|
|
|
|
|
|
locale === "en-US"
|
|
|
|
|
|
? `Quote age ${quoteAgeSeconds}s; refresh before acting.`
|
|
|
|
|
|
: `报价已 ${quoteAgeSeconds}s,执行前需要刷新。`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (row.trend_alignment === false) {
|
|
|
|
|
|
hints.push(
|
|
|
|
|
|
locale === "en-US"
|
|
|
|
|
|
? "Intraday trend does not fully support this direction."
|
|
|
|
|
|
: "日内趋势未完全支持该方向。",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (row.cluster_adjusted) {
|
|
|
|
|
|
hints.push(
|
|
|
|
|
|
locale === "en-US"
|
|
|
|
|
|
? "Tail bucket was cluster-adjusted; raw edge may be overstated."
|
|
|
|
|
|
: "尾部桶已做模型集群折扣,原始 edge 可能偏乐观。",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (modelProbability != null && modelProbability < 10) {
|
|
|
|
|
|
hints.push(
|
|
|
|
|
|
locale === "en-US"
|
|
|
|
|
|
? "Low model probability makes the setup sensitive to calibration error."
|
|
|
|
|
|
: "模型概率偏低,校准误差会显著影响判断。",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!hints.length) {
|
|
|
|
|
|
hints.push(
|
|
|
|
|
|
locale === "en-US"
|
|
|
|
|
|
? "Main residual risk is late observation updates or a shifted peak window."
|
|
|
|
|
|
: "主要残余风险是后续实测升温或峰值窗口漂移。",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return hints;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getRecommendationReasons(
|
|
|
|
|
|
row: ScanOpportunityRow,
|
|
|
|
|
|
locale: string,
|
|
|
|
|
|
modelProbability?: number | null,
|
|
|
|
|
|
edgePercent?: number | null,
|
|
|
|
|
|
price?: number | null,
|
|
|
|
|
|
) {
|
|
|
|
|
|
const reasons: string[] = [];
|
|
|
|
|
|
const aiReason = getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en);
|
|
|
|
|
|
if (aiReason && String(row.ai_decision || "").toLowerCase() === "approve") {
|
|
|
|
|
|
reasons.push(aiReason);
|
|
|
|
|
|
}
|
|
|
|
|
|
reasons.push(
|
|
|
|
|
|
locale === "en-US"
|
|
|
|
|
|
? `EMOS probability ${formatPercent(modelProbability)} vs market price ${formatQuoteCents(price)} gives edge ${formatPercent(edgePercent, true)}.`
|
|
|
|
|
|
: `EMOS 概率 ${formatPercent(modelProbability)} 对比市场价格 ${formatQuoteCents(price)},edge ${formatPercent(edgePercent, true)}。`,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (row.peak_alignment_score != null) {
|
|
|
|
|
|
reasons.push(
|
|
|
|
|
|
locale === "en-US"
|
|
|
|
|
|
? `Peak alignment score ${Number(row.peak_alignment_score).toFixed(2)} supports checking this bucket.`
|
|
|
|
|
|
: `峰值对齐分 ${Number(row.peak_alignment_score).toFixed(2)},支持把该桶纳入检查。`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return reasons.slice(0, 3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getExclusionReasons(
|
|
|
|
|
|
row: ScanOpportunityRow,
|
|
|
|
|
|
locale: string,
|
|
|
|
|
|
edgePercent?: number | null,
|
|
|
|
|
|
) {
|
|
|
|
|
|
const decision = String(row.ai_decision || "").toLowerCase();
|
|
|
|
|
|
const aiReason =
|
|
|
|
|
|
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
|
|
|
|
|
|
getLocalizedRowText(
|
|
|
|
|
|
row,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
row.ai_watchlist_reason_zh,
|
|
|
|
|
|
row.ai_watchlist_reason_en,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (decision === "veto" || decision === "downgrade" || decision === "watchlist") {
|
|
|
|
|
|
return [
|
|
|
|
|
|
aiReason ||
|
|
|
|
|
|
(locale === "en-US"
|
|
|
|
|
|
? "V4 did not classify this row as a primary recommendation."
|
|
|
|
|
|
: "V4 未把该合约列为主推荐。"),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (edgePercent != null && Number(edgePercent) < 10) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
locale === "en-US"
|
|
|
|
|
|
? "Edge is below the medium-opportunity threshold."
|
|
|
|
|
|
: "edge 低于中机会阈值。",
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
return [
|
|
|
|
|
|
locale === "en-US"
|
|
|
|
|
|
? "No hard veto in the current V4/rule snapshot."
|
|
|
|
|
|
: "当前 V4/规则快照没有硬性排除项。",
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 23:32:32 +08:00
|
|
|
|
function getAiMeta(row: ScanOpportunityRow, locale: string) {
|
|
|
|
|
|
const decision = String(row.ai_decision || "").toLowerCase();
|
|
|
|
|
|
if (decision === "veto") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? "AI veto" : "AI 排除",
|
|
|
|
|
|
tone: "veto",
|
|
|
|
|
|
reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (decision === "downgrade") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? "AI downgrade" : "AI 降级",
|
|
|
|
|
|
tone: "downgrade",
|
|
|
|
|
|
reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (row.ai_rank != null || decision === "approve") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? `AI pick ${row.ai_rank || ""}`.trim() : `AI 推荐 ${row.ai_rank || ""}`.trim(),
|
|
|
|
|
|
tone: "approve",
|
|
|
|
|
|
reason:
|
|
|
|
|
|
(locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en) ||
|
|
|
|
|
|
row.ai_model_cluster_note ||
|
|
|
|
|
|
null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-04-25 02:19:37 +08:00
|
|
|
|
if (decision === "watchlist") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: locale === "en-US" ? "AI watch" : "AI 观察",
|
|
|
|
|
|
tone: "downgrade",
|
|
|
|
|
|
reason:
|
|
|
|
|
|
locale === "en-US"
|
|
|
|
|
|
? row.ai_watchlist_reason_en || row.ai_watchlist_reason_zh
|
|
|
|
|
|
: row.ai_watchlist_reason_zh || row.ai_watchlist_reason_en,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-04-24 23:32:32 +08:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 14:41:33 +08:00
|
|
|
|
function getDistributionPreview(row: ScanOpportunityRow, tempSymbol?: string | null) {
|
2026-04-24 00:34:09 +08:00
|
|
|
|
const preview = Array.isArray(row.distribution_preview)
|
|
|
|
|
|
? row.distribution_preview.filter(
|
|
|
|
|
|
(item): item is DistributionPreviewPoint =>
|
|
|
|
|
|
Boolean(item && (item.label || item.value != null)),
|
|
|
|
|
|
)
|
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
|
|
if (!preview.length) {
|
|
|
|
|
|
const targetBase =
|
|
|
|
|
|
row.target_value ??
|
|
|
|
|
|
row.target_threshold ??
|
|
|
|
|
|
row.target_lower ??
|
|
|
|
|
|
row.target_upper ??
|
|
|
|
|
|
null;
|
|
|
|
|
|
const targetLabel =
|
|
|
|
|
|
targetBase != null
|
2026-04-24 02:14:20 +08:00
|
|
|
|
? formatTemperatureValue(Number(targetBase), tempSymbol)
|
|
|
|
|
|
: normalizeTemperatureLabel(row.target_label, tempSymbol) || "--";
|
2026-04-24 00:34:09 +08:00
|
|
|
|
preview.push({
|
|
|
|
|
|
label: targetLabel,
|
2026-04-25 00:08:21 +08:00
|
|
|
|
model_probability:
|
|
|
|
|
|
row.raw_model_event_probability ?? row.model_event_probability,
|
2026-04-24 00:34:09 +08:00
|
|
|
|
market_probability: row.market_event_probability,
|
|
|
|
|
|
highlighted: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-24 14:41:33 +08:00
|
|
|
|
return preview;
|
|
|
|
|
|
}
|
2026-04-24 00:34:09 +08:00
|
|
|
|
|
2026-04-24 14:41:33 +08:00
|
|
|
|
function getEmosPeak(row: ScanOpportunityRow, tempSymbol?: string | null) {
|
|
|
|
|
|
const preview = getDistributionPreview(row, tempSymbol);
|
2026-04-24 14:34:25 +08:00
|
|
|
|
const peak =
|
|
|
|
|
|
preview.reduce<DistributionPreviewPoint | null>((best, item) => {
|
|
|
|
|
|
const probability = Number(item.model_probability ?? -1);
|
|
|
|
|
|
const bestProbability = Number(best?.model_probability ?? -1);
|
|
|
|
|
|
return probability > bestProbability ? item : best;
|
|
|
|
|
|
}, null) || preview.find((item) => item.highlighted) || preview[0];
|
|
|
|
|
|
const peakValue = peak?.value ?? row.peak_value ?? null;
|
2026-04-24 14:41:33 +08:00
|
|
|
|
const peakLabel = normalizeTemperatureLabel(peak?.label, tempSymbol) ||
|
|
|
|
|
|
(peakValue != null ? formatTemperatureValue(Number(peakValue), tempSymbol) : "--");
|
2026-04-24 14:34:25 +08:00
|
|
|
|
const peakProbability =
|
|
|
|
|
|
peak?.model_probability != null
|
|
|
|
|
|
? Number(peak.model_probability) * 100
|
|
|
|
|
|
: row.peak_probability != null
|
|
|
|
|
|
? Number(row.peak_probability) * 100
|
|
|
|
|
|
: null;
|
2026-04-24 14:41:33 +08:00
|
|
|
|
return { label: peakLabel, probability: peakProbability };
|
|
|
|
|
|
}
|
2026-04-24 06:31:29 +08:00
|
|
|
|
|
2026-04-25 02:41:38 +08:00
|
|
|
|
function normalizeLookupKey(value?: string | null) {
|
|
|
|
|
|
return String(value || "")
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
.replace(/[\s_-]+/g, "");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 01:41:45 +08:00
|
|
|
|
function getDetailForRow(
|
|
|
|
|
|
row: Pick<ScanOpportunityRow, "city" | "city_display_name" | "display_name">,
|
|
|
|
|
|
cityDetailsByName?: Record<string, CityDetail>,
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (!cityDetailsByName) return null;
|
|
|
|
|
|
const rowKeys = [row.city, row.city_display_name, row.display_name]
|
2026-04-25 02:41:38 +08:00
|
|
|
|
.map(normalizeLookupKey)
|
2026-04-25 01:41:45 +08:00
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
return (
|
|
|
|
|
|
Object.entries(cityDetailsByName).find(([name, detail]) => {
|
|
|
|
|
|
const detailKeys = [name, detail.name, detail.display_name]
|
2026-04-25 02:41:38 +08:00
|
|
|
|
.map(normalizeLookupKey)
|
2026-04-25 01:41:45 +08:00
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
return rowKeys.some((key) => detailKeys.includes(key));
|
|
|
|
|
|
})?.[1] || null
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 02:41:38 +08:00
|
|
|
|
function getDetailViewDate(detail: CityDetail, row?: ScanOpportunityRow | null) {
|
|
|
|
|
|
if (!row) return detail.local_date;
|
|
|
|
|
|
const rawDate = row.selected_date || row.local_date || "";
|
|
|
|
|
|
const phase = String(row.window_phase || "").toLowerCase();
|
|
|
|
|
|
if ((phase === "tomorrow" || phase === "week_ahead") && rawDate) return rawDate;
|
|
|
|
|
|
if (!rawDate || rawDate === detail.local_date || row.local_date === detail.local_date) {
|
|
|
|
|
|
return detail.local_date;
|
|
|
|
|
|
}
|
|
|
|
|
|
return detail.local_date || rawDate;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeBucketLabel(value?: string | null, tempSymbol?: string | null) {
|
|
|
|
|
|
return normalizeTemperatureLabel(value, tempSymbol)
|
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
.replace(/\s+/g, "")
|
|
|
|
|
|
.replace(/℃/g, "°c");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function extractNumbers(value?: string | null) {
|
|
|
|
|
|
return Array.from(String(value || "").matchAll(/-?\d+(?:\.\d+)?/g)).map((match) =>
|
|
|
|
|
|
Number(match[0]),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getBucketText(bucket: { label?: string | null; bucket?: string | null; range?: string | null }) {
|
|
|
|
|
|
return [bucket.label, bucket.bucket, bucket.range]
|
|
|
|
|
|
.map((value) => String(value || "").trim())
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function bucketMatchesRow(
|
|
|
|
|
|
bucket: {
|
|
|
|
|
|
label?: string | null;
|
|
|
|
|
|
bucket?: string | null;
|
|
|
|
|
|
range?: string | null;
|
|
|
|
|
|
value?: number | string | null;
|
|
|
|
|
|
},
|
|
|
|
|
|
row: ScanOpportunityRow,
|
|
|
|
|
|
tempSymbol?: string | null,
|
|
|
|
|
|
) {
|
|
|
|
|
|
const targetLabel = normalizeBucketLabel(row.target_label, tempSymbol);
|
|
|
|
|
|
const bucketLabels = getBucketText(bucket).map((label) =>
|
|
|
|
|
|
normalizeBucketLabel(label, tempSymbol),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (targetLabel && bucketLabels.some((label) => label === targetLabel)) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const rawTargetLabel = String(row.target_label || "");
|
|
|
|
|
|
const targetNumbers = extractNumbers(rawTargetLabel);
|
|
|
|
|
|
const targetValue =
|
|
|
|
|
|
row.target_value ?? row.target_threshold ?? row.target_lower ?? row.target_upper ?? targetNumbers[0] ?? null;
|
|
|
|
|
|
if (targetValue == null || !Number.isFinite(Number(targetValue))) return false;
|
|
|
|
|
|
|
|
|
|
|
|
const bucketNumbers = [
|
|
|
|
|
|
...(bucket.value != null && Number.isFinite(Number(bucket.value))
|
|
|
|
|
|
? [Number(bucket.value)]
|
|
|
|
|
|
: []),
|
|
|
|
|
|
...getBucketText(bucket).flatMap(extractNumbers),
|
|
|
|
|
|
];
|
|
|
|
|
|
const matchesNumber = bucketNumbers.some(
|
|
|
|
|
|
(value) => Math.abs(Number(value) - Number(targetValue)) < 0.05,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!matchesNumber) return false;
|
|
|
|
|
|
|
|
|
|
|
|
const targetIsUpper =
|
|
|
|
|
|
/(\+|以上|or\s*above|above|greater|>=|≥)/i.test(rawTargetLabel) ||
|
|
|
|
|
|
(row.target_lower != null && row.target_upper == null);
|
|
|
|
|
|
const targetIsLower =
|
|
|
|
|
|
/(<=|≤|below|or\s*below|以下)/i.test(rawTargetLabel) ||
|
|
|
|
|
|
(row.target_upper != null && row.target_lower == null);
|
|
|
|
|
|
const bucketRaw = getBucketText(bucket).join(" ");
|
|
|
|
|
|
const bucketIsUpper = /(\+|以上|or\s*above|above|greater|>=|≥|inf|∞)/i.test(bucketRaw);
|
|
|
|
|
|
const bucketIsLower = /(<=|≤|below|or\s*below|以下|-inf|-∞)/i.test(bucketRaw);
|
|
|
|
|
|
|
|
|
|
|
|
if (targetIsUpper || bucketIsUpper) return targetIsUpper === bucketIsUpper;
|
|
|
|
|
|
if (targetIsLower || bucketIsLower) return targetIsLower === bucketIsLower;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 01:41:45 +08:00
|
|
|
|
function getDetailPeak(
|
|
|
|
|
|
detail: CityDetail | null,
|
2026-04-25 02:41:38 +08:00
|
|
|
|
row?: ScanOpportunityRow | null,
|
2026-04-25 01:41:45 +08:00
|
|
|
|
tempSymbol?: string | null,
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (!detail) return null;
|
2026-04-25 02:41:38 +08:00
|
|
|
|
const view = getProbabilityView(detail, getDetailViewDate(detail, row));
|
2026-04-25 01:41:45 +08:00
|
|
|
|
const buckets = Array.isArray(view.probabilitiesAll)
|
|
|
|
|
|
? view.probabilitiesAll
|
|
|
|
|
|
: [];
|
|
|
|
|
|
const peak = [...buckets]
|
|
|
|
|
|
.filter((bucket) => normalizeProbability(bucket?.probability) != null)
|
|
|
|
|
|
.sort(
|
|
|
|
|
|
(a, b) =>
|
|
|
|
|
|
Number(normalizeProbability(b?.probability)) -
|
|
|
|
|
|
Number(normalizeProbability(a?.probability)),
|
|
|
|
|
|
)[0];
|
|
|
|
|
|
if (!peak) return null;
|
|
|
|
|
|
const peakValue = peak.value ?? null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
label:
|
|
|
|
|
|
normalizeTemperatureLabel(peak.label || peak.bucket || peak.range, tempSymbol) ||
|
|
|
|
|
|
(peakValue != null ? formatTemperatureValue(Number(peakValue), tempSymbol) : "--"),
|
|
|
|
|
|
probability: Number(normalizeProbability(peak.probability)) * 100,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getDetailBucketEventProbability(
|
|
|
|
|
|
detail: CityDetail | null,
|
|
|
|
|
|
row: ScanOpportunityRow,
|
|
|
|
|
|
tempSymbol?: string | null,
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (!detail) return null;
|
2026-04-25 02:41:38 +08:00
|
|
|
|
const view = getProbabilityView(detail, getDetailViewDate(detail, row));
|
2026-04-25 01:41:45 +08:00
|
|
|
|
const buckets = Array.isArray(view.probabilitiesAll)
|
|
|
|
|
|
? view.probabilitiesAll
|
|
|
|
|
|
: [];
|
|
|
|
|
|
if (!buckets.length) return null;
|
2026-04-25 02:41:38 +08:00
|
|
|
|
const matched = buckets.find((bucket) => bucketMatchesRow(bucket, row, tempSymbol));
|
2026-04-25 01:41:45 +08:00
|
|
|
|
return normalizeProbability(matched?.probability);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 14:41:33 +08:00
|
|
|
|
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[];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-25 01:41:45 +08:00
|
|
|
|
function buildOpportunityGroups(
|
|
|
|
|
|
rows: ScanOpportunityRow[],
|
|
|
|
|
|
locale: string,
|
|
|
|
|
|
cityDetailsByName?: Record<string, CityDetail>,
|
|
|
|
|
|
): OpportunityGroup[] {
|
2026-04-24 14:41:33 +08:00
|
|
|
|
const groups = new Map<string, OpportunityGroup>();
|
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
|
const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
|
2026-04-25 01:41:45 +08:00
|
|
|
|
const detail = getDetailForRow(row, cityDetailsByName);
|
2026-04-24 14:41:33 +08:00
|
|
|
|
const cityName = getLocalizedCityName(
|
|
|
|
|
|
row.city,
|
|
|
|
|
|
row.city_display_name || row.display_name || row.city,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
);
|
2026-04-25 02:41:38 +08:00
|
|
|
|
const date = detail ? getDetailViewDate(detail, row) : row.selected_date || row.local_date || "";
|
2026-04-24 14:41:33 +08:00
|
|
|
|
const key = `${row.city || cityName}|${date}`;
|
2026-04-25 02:41:38 +08:00
|
|
|
|
const peak = getDetailPeak(detail, row, tempSymbol) || getEmosPeak(row, tempSymbol);
|
2026-04-25 01:41:45 +08:00
|
|
|
|
const modelView = detail ? getModelView(detail, date) : null;
|
|
|
|
|
|
const debPrediction = modelView?.deb ?? row.deb_prediction ?? null;
|
2026-04-24 14:41:33 +08:00
|
|
|
|
const existing = groups.get(key);
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
|
groups.set(key, {
|
|
|
|
|
|
key,
|
|
|
|
|
|
cityName,
|
|
|
|
|
|
date,
|
|
|
|
|
|
tempSymbol,
|
|
|
|
|
|
debLabel:
|
2026-04-25 01:41:45 +08:00
|
|
|
|
debPrediction != null
|
|
|
|
|
|
? formatTemperatureValue(Number(debPrediction), tempSymbol, { digits: 1 })
|
2026-04-24 14:41:33 +08:00
|
|
|
|
: "--",
|
|
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
}));
|
2026-04-23 23:49:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 05:39:14 +08:00
|
|
|
|
export const OpportunityTable = React.memo(function OpportunityTable({
|
2026-04-23 23:49:52 +08:00
|
|
|
|
rows,
|
2026-04-24 10:19:16 +08:00
|
|
|
|
status,
|
|
|
|
|
|
stale,
|
|
|
|
|
|
staleReason,
|
|
|
|
|
|
loading,
|
2026-04-23 23:49:52 +08:00
|
|
|
|
selectedRowId,
|
|
|
|
|
|
onSelectRow,
|
2026-04-25 01:41:45 +08:00
|
|
|
|
cityDetailsByName,
|
2026-04-23 23:49:52 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
rows: ScanOpportunityRow[];
|
2026-04-24 10:19:16 +08:00
|
|
|
|
status?: string | null;
|
|
|
|
|
|
stale?: boolean;
|
|
|
|
|
|
staleReason?: string | null;
|
|
|
|
|
|
loading?: boolean;
|
2026-04-23 23:49:52 +08:00
|
|
|
|
selectedRowId?: string | null;
|
|
|
|
|
|
onSelectRow?: (row: ScanOpportunityRow) => void;
|
2026-04-25 01:41:45 +08:00
|
|
|
|
cityDetailsByName?: Record<string, CityDetail>;
|
2026-04-23 23:49:52 +08:00
|
|
|
|
}) {
|
2026-04-23 22:48:57 +08:00
|
|
|
|
const { locale } = useI18n();
|
|
|
|
|
|
const isEn = locale === "en-US";
|
2026-04-24 10:19:16 +08:00
|
|
|
|
const hasRows = rows.length > 0;
|
2026-04-24 12:46:44 +08:00
|
|
|
|
const scanInProgress =
|
|
|
|
|
|
loading || status === "partial" || status === "scanning";
|
2026-04-24 14:41:33 +08:00
|
|
|
|
const groups = React.useMemo(
|
2026-04-25 01:41:45 +08:00
|
|
|
|
() => buildOpportunityGroups(rows, locale, cityDetailsByName),
|
|
|
|
|
|
[rows, locale, cityDetailsByName],
|
2026-04-24 14:41:33 +08:00
|
|
|
|
);
|
2026-04-25 03:26:28 +08:00
|
|
|
|
const [expandedRowIds, setExpandedRowIds] = React.useState<Set<string>>(
|
|
|
|
|
|
() => new Set(),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const toggleExpandedRow = React.useCallback((rowId: string) => {
|
|
|
|
|
|
setExpandedRowIds((current) => {
|
|
|
|
|
|
const next = new Set(current);
|
|
|
|
|
|
if (next.has(rowId)) {
|
|
|
|
|
|
next.delete(rowId);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
next.add(rowId);
|
|
|
|
|
|
}
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
2026-04-23 22:48:57 +08:00
|
|
|
|
|
2026-04-24 10:19:16 +08:00
|
|
|
|
if (!hasRows) {
|
|
|
|
|
|
const title =
|
2026-04-24 12:46:44 +08:00
|
|
|
|
scanInProgress
|
2026-04-24 10:19:16 +08:00
|
|
|
|
? isEn
|
|
|
|
|
|
? "Scanning markets"
|
|
|
|
|
|
: "正在扫描市场"
|
|
|
|
|
|
: status === "failed"
|
|
|
|
|
|
? isEn
|
|
|
|
|
|
? "Scan failed"
|
|
|
|
|
|
: "扫描失败"
|
|
|
|
|
|
: isEn
|
|
|
|
|
|
? "No tradable market right now"
|
|
|
|
|
|
: "当前暂无可交易市场";
|
|
|
|
|
|
const copy =
|
2026-04-24 12:46:44 +08:00
|
|
|
|
scanInProgress
|
2026-04-24 10:19:16 +08:00
|
|
|
|
? isEn
|
|
|
|
|
|
? "Waiting for the latest market snapshot. Existing data will stay on screen when available."
|
|
|
|
|
|
: "正在等待最新市场快照;如果有旧数据,会继续保留在页面上。"
|
|
|
|
|
|
: status === "failed"
|
|
|
|
|
|
? staleReason || (isEn ? "No valid market snapshot is available." : "当前没有可用的市场快照。")
|
|
|
|
|
|
: isEn
|
|
|
|
|
|
? "The current snapshot does not contain a tradable main signal."
|
|
|
|
|
|
: "当前快照里还没有可交易的主信号。";
|
2026-04-24 00:13:11 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="scan-table-shell empty">
|
|
|
|
|
|
<div className="scan-empty-state">
|
2026-04-24 10:19:16 +08:00
|
|
|
|
<div className="scan-empty-title">{title}</div>
|
|
|
|
|
|
<div className="scan-empty-copy">{copy}</div>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="scan-table-shell">
|
2026-04-24 10:19:16 +08:00
|
|
|
|
{stale ? (
|
|
|
|
|
|
<div className="scan-table-banner">
|
|
|
|
|
|
<strong>{isEn ? "Showing delayed snapshot" : "当前显示延迟快照"}</strong>
|
|
|
|
|
|
<span>{staleReason || (isEn ? "Latest refresh failed, fallback to the last successful scan." : "最新刷新失败,已回退到上次成功扫描结果。")}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-04-24 14:41:33 +08:00
|
|
|
|
<div className="scan-table-body scan-opportunity-groups">
|
2026-04-25 01:41:45 +08:00
|
|
|
|
{groups.map((group) => {
|
|
|
|
|
|
const groupSelected = group.rows.some((row) => row.id === selectedRowId);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section
|
|
|
|
|
|
key={group.key}
|
|
|
|
|
|
className={`scan-opportunity-group ${groupSelected ? "selected" : ""}`}
|
|
|
|
|
|
>
|
2026-04-25 00:08:21 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="scan-opportunity-group-head"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const firstRow = group.rows[0];
|
|
|
|
|
|
if (firstRow) onSelectRow?.(firstRow);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-24 14:41:33 +08:00
|
|
|
|
<div className="scan-opportunity-city">
|
|
|
|
|
|
<strong>{group.cityName}</strong>
|
2026-04-24 15:00:02 +08:00
|
|
|
|
<div className="scan-opportunity-models">
|
2026-04-25 03:26:28 +08:00
|
|
|
|
<span>
|
|
|
|
|
|
<em>{isEn ? "Local time" : "当前时间"}</em>
|
|
|
|
|
|
<b>{group.localTime || "--"}</b>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<em>{isEn ? "Settlement left" : "剩余结算时间"}</em>
|
|
|
|
|
|
<b>{formatWindowMinutes(group.remainingMinutes, locale)}</b>
|
|
|
|
|
|
</span>
|
2026-04-24 15:00:02 +08:00
|
|
|
|
<span>
|
|
|
|
|
|
<em>DEB</em>
|
|
|
|
|
|
<b>{group.debLabel}</b>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<em>EMOS peak</em>
|
|
|
|
|
|
<b>{group.peakLabel}</b>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{group.peakProbability != null ? (
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<em>{isEn ? "Peak prob" : "峰值概率"}</em>
|
|
|
|
|
|
<b>{formatPercent(group.peakProbability)}</b>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</div>
|
2026-04-24 14:41:33 +08:00
|
|
|
|
<div className="scan-opportunity-phase">
|
|
|
|
|
|
<b className={`scan-phase-badge ${group.phaseMeta.tone}`}>
|
|
|
|
|
|
{group.phaseMeta.label}
|
|
|
|
|
|
</b>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</div>
|
2026-04-25 00:08:21 +08:00
|
|
|
|
</button>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
|
2026-04-24 14:41:33 +08:00
|
|
|
|
<div className="scan-opportunity-items">
|
2026-04-25 03:26:28 +08:00
|
|
|
|
{group.rows.map((row) => {
|
2026-04-24 14:41:33 +08:00
|
|
|
|
const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
|
|
|
|
|
|
const side = String(row.side || "").toLowerCase();
|
2026-04-25 01:41:45 +08:00
|
|
|
|
const detail = getDetailForRow(row, cityDetailsByName);
|
|
|
|
|
|
const detailEventProbability = getDetailBucketEventProbability(
|
|
|
|
|
|
detail,
|
|
|
|
|
|
row,
|
|
|
|
|
|
tempSymbol,
|
|
|
|
|
|
);
|
2026-04-24 14:41:33 +08:00
|
|
|
|
const modelProbability =
|
2026-04-25 01:41:45 +08:00
|
|
|
|
detailEventProbability != null
|
|
|
|
|
|
? (side === "no" ? 1 - detailEventProbability : detailEventProbability) * 100
|
|
|
|
|
|
: row.model_probability != null
|
|
|
|
|
|
? Number(row.model_probability) * 100
|
|
|
|
|
|
: row.raw_model_event_probability != null
|
|
|
|
|
|
? (side === "no"
|
|
|
|
|
|
? 1 - Number(row.raw_model_event_probability)
|
|
|
|
|
|
: Number(row.raw_model_event_probability)) * 100
|
2026-04-24 14:41:33 +08:00
|
|
|
|
: row.model_event_probability != null
|
2026-04-25 01:41:45 +08:00
|
|
|
|
? (side === "no"
|
|
|
|
|
|
? 1 - Number(row.model_event_probability)
|
|
|
|
|
|
: Number(row.model_event_probability)) * 100
|
2026-04-24 14:41:33 +08:00
|
|
|
|
: null;
|
2026-04-25 01:41:45 +08:00
|
|
|
|
const edgePercent =
|
|
|
|
|
|
modelProbability != null && row.ask != null
|
|
|
|
|
|
? modelProbability - Number(row.ask) * 100
|
|
|
|
|
|
: row.edge_percent;
|
2026-04-25 03:26:28 +08:00
|
|
|
|
const modelLabel = isEn ? "EMOS prob" : "EMOS 概率";
|
|
|
|
|
|
const priceLabel = isEn ? "Market price" : "市场价格";
|
2026-04-25 01:41:45 +08:00
|
|
|
|
const edgePositive = Number(edgePercent || 0) >= 0;
|
2026-04-24 23:32:32 +08:00
|
|
|
|
const aiMeta = getAiMeta(row, locale);
|
2026-04-25 03:26:28 +08:00
|
|
|
|
const strength = getOpportunityStrength(edgePercent, locale);
|
|
|
|
|
|
const expanded = expandedRowIds.has(row.id);
|
|
|
|
|
|
const shortConclusion = getShortAiConclusion(
|
|
|
|
|
|
row,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
edgePercent,
|
|
|
|
|
|
strength.label,
|
|
|
|
|
|
);
|
|
|
|
|
|
const recommendationReasons = getRecommendationReasons(
|
|
|
|
|
|
row,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
modelProbability,
|
|
|
|
|
|
edgePercent,
|
|
|
|
|
|
row.ask,
|
|
|
|
|
|
);
|
|
|
|
|
|
const exclusionReasons = getExclusionReasons(row, locale, edgePercent);
|
|
|
|
|
|
const riskHints = getRiskHints(row, locale, modelProbability);
|
|
|
|
|
|
const thesis =
|
|
|
|
|
|
getLocalizedRowText(
|
|
|
|
|
|
row,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
row.ai_city_thesis_zh,
|
|
|
|
|
|
row.ai_city_thesis_en,
|
|
|
|
|
|
) ||
|
|
|
|
|
|
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
|
|
|
|
|
|
(isEn
|
2026-04-25 03:59:02 +08:00
|
|
|
|
? `${group.cityName} thesis: validate this ${formatTradeSide(row, locale, tempSymbol)} against the full model cluster before sizing.`
|
|
|
|
|
|
: `${group.cityName} thesis:该 ${formatTradeSide(row, locale, tempSymbol)} 需要先结合全部模型集群确认,再考虑仓位。`);
|
2026-04-25 03:26:28 +08:00
|
|
|
|
const modelSources = formatModelSources(row, tempSymbol);
|
2026-04-24 14:41:33 +08:00
|
|
|
|
return (
|
2026-04-25 01:41:45 +08:00
|
|
|
|
<div
|
2026-04-24 14:41:33 +08:00
|
|
|
|
key={row.id}
|
2026-04-25 03:26:28 +08:00
|
|
|
|
className={`scan-opportunity-item ${selectedRowId === row.id ? "selected" : ""} ${aiMeta ? `ai-${aiMeta.tone}` : ""}`}
|
|
|
|
|
|
onClick={() => onSelectRow?.(row)}
|
2026-04-24 14:41:33 +08:00
|
|
|
|
>
|
2026-04-24 15:00:02 +08:00
|
|
|
|
<span className="scan-opportunity-branch" aria-hidden="true">
|
|
|
|
|
|
<i />
|
2026-04-24 14:41:33 +08:00
|
|
|
|
</span>
|
2026-04-24 15:00:02 +08:00
|
|
|
|
<span className="scan-opportunity-trade">
|
|
|
|
|
|
<strong className={`scan-opportunity-action ${side === "no" ? "sell" : "buy"}`}>
|
2026-04-25 03:59:02 +08:00
|
|
|
|
{formatTradeSide(row, locale, tempSymbol)}
|
2026-04-24 15:00:02 +08:00
|
|
|
|
</strong>
|
|
|
|
|
|
</span>
|
2026-04-25 03:26:28 +08:00
|
|
|
|
<span className="scan-opportunity-stat threshold">
|
|
|
|
|
|
<small>{isEn ? "Threshold" : "阈值"}</small>
|
|
|
|
|
|
<b>{formatThreshold(row, tempSymbol)}</b>
|
|
|
|
|
|
</span>
|
2026-04-24 15:00:02 +08:00
|
|
|
|
<span className="scan-opportunity-stat">
|
2026-04-24 15:12:53 +08:00
|
|
|
|
<small>{modelLabel}</small>
|
2026-04-24 15:00:02 +08:00
|
|
|
|
<b>{formatPercent(modelProbability)}</b>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="scan-opportunity-stat">
|
|
|
|
|
|
<small>{priceLabel}</small>
|
|
|
|
|
|
<b>{formatQuoteCents(row.ask)}</b>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="scan-opportunity-stat edge">
|
|
|
|
|
|
<small>edge</small>
|
|
|
|
|
|
<b className={edgePositive ? "positive" : "negative"}>
|
2026-04-25 01:41:45 +08:00
|
|
|
|
{formatPercent(edgePercent, true)}
|
2026-04-24 15:00:02 +08:00
|
|
|
|
</b>
|
2026-04-24 14:41:33 +08:00
|
|
|
|
</span>
|
2026-04-25 03:26:28 +08:00
|
|
|
|
<span className={`scan-opportunity-strength ${strength.tone}`}>
|
|
|
|
|
|
{strength.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="scan-opportunity-expand"
|
|
|
|
|
|
aria-expanded={expanded}
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
toggleExpandedRow(row.id);
|
|
|
|
|
|
onSelectRow?.(row);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<BarChart3 size={14} />
|
|
|
|
|
|
{expanded
|
|
|
|
|
|
? isEn
|
|
|
|
|
|
? "Hide analysis"
|
|
|
|
|
|
: "收起分析"
|
|
|
|
|
|
: isEn
|
|
|
|
|
|
? "Full analysis"
|
|
|
|
|
|
: "查看完整分析"}
|
|
|
|
|
|
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span className={`scan-opportunity-ai ${aiMeta?.tone || "neutral"}`}>
|
|
|
|
|
|
<b>{isEn ? "AI take" : "AI 结论"}</b>
|
|
|
|
|
|
<small>{shortConclusion}</small>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{expanded ? (
|
|
|
|
|
|
<div className="scan-v4-analysis">
|
|
|
|
|
|
<section>
|
|
|
|
|
|
<strong>thesis</strong>
|
|
|
|
|
|
<p>{thesis}</p>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
<section>
|
|
|
|
|
|
<strong>{isEn ? "Recommendation reasons" : "推荐理由"}</strong>
|
|
|
|
|
|
<ul>
|
|
|
|
|
|
{recommendationReasons.map((reason) => (
|
|
|
|
|
|
<li key={reason}>{reason}</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
<section>
|
|
|
|
|
|
<strong>{isEn ? "Exclusion reasons" : "排除理由"}</strong>
|
|
|
|
|
|
<ul>
|
|
|
|
|
|
{exclusionReasons.map((reason) => (
|
|
|
|
|
|
<li key={reason}>{reason}</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
<section>
|
|
|
|
|
|
<strong>{isEn ? "Risk notes" : "风险提示"}</strong>
|
|
|
|
|
|
<ul>
|
|
|
|
|
|
{riskHints.map((reason) => (
|
|
|
|
|
|
<li key={reason}>{reason}</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
<section className="scan-v4-evidence">
|
|
|
|
|
|
<strong>{isEn ? "Data basis" : "数据依据"}</strong>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span>DEB {group.debLabel}</span>
|
|
|
|
|
|
<span>EMOS peak {group.peakLabel}</span>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{isEn ? "Peak prob" : "峰值概率"}{" "}
|
|
|
|
|
|
{formatPercent(group.peakProbability)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{isEn ? "Ask" : "买价"} {formatQuoteCents(row.ask)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span>edge {formatPercent(edgePercent, true)}</span>
|
|
|
|
|
|
{row.kelly_fraction != null ? (
|
|
|
|
|
|
<span>
|
|
|
|
|
|
Kelly {formatPercent(Number(row.kelly_fraction) * 100)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="scan-v4-model-sources">
|
|
|
|
|
|
{modelSources.length ? (
|
|
|
|
|
|
modelSources.map((source) => (
|
|
|
|
|
|
<span key={source.name}>
|
|
|
|
|
|
<em>{source.name}</em>
|
|
|
|
|
|
<b>{source.value}</b>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<em>{isEn ? "Models" : "模型"}</em>
|
|
|
|
|
|
<b>
|
|
|
|
|
|
{isEn
|
|
|
|
|
|
? "waiting for cluster"
|
|
|
|
|
|
: "等待模型集群"}
|
|
|
|
|
|
</b>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
2026-04-24 23:32:32 +08:00
|
|
|
|
) : null}
|
2026-04-25 01:41:45 +08:00
|
|
|
|
</div>
|
2026-04-24 14:41:33 +08:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
2026-04-25 01:41:45 +08:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</div>
|
2026-04-23 22:48:57 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-04-24 05:39:14 +08:00
|
|
|
|
});
|