Add Groq commentary config and simplify forecast modal

This commit is contained in:
2569718930@qq.com
2026-04-08 11:41:34 +08:00
parent 1d937728ee
commit f0904dbcc5
6 changed files with 312 additions and 550 deletions
@@ -19,7 +19,6 @@ import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import { IntradaySignalScene } from "@/components/dashboard/IntradaySignalScene";
import {
ModelForecast,
ProbabilityDistribution,
@@ -29,7 +28,6 @@ import {
getModelView,
getProbabilityView,
getTodayPaceView,
parseAiAnalysis,
getTemperatureChartData,
getWeatherSummary,
} from "@/lib/dashboard-utils";
@@ -63,14 +61,6 @@ function formatMarketPercent(value?: number | null) {
return `${(normalized * 100).toFixed(1)}%`;
}
function formatMarketPriceCents(value?: number | null) {
const normalized = normalizeMarketValue(value);
if (normalized == null) return "--";
const cents = normalized * 100;
const rounded = Math.round(cents * 10) / 10;
return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}¢`;
}
function formatSignedPercent(value?: number | null) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return "--";
@@ -78,26 +68,6 @@ function formatSignedPercent(value?: number | null) {
return `${sign}${numeric.toFixed(1)}%`;
}
function formatSpreadPercent(low?: number | null, high?: number | null) {
const a = normalizeMarketValue(low);
const b = normalizeMarketValue(high);
if (a == null || b == null) return "--";
const spreadCents = Math.abs((b - a) * 100);
const rounded = Math.round(spreadCents * 10) / 10;
return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}¢`;
}
function resolveCounterPrice(
directValue?: number | null,
mirrorValue?: number | null,
) {
const direct = normalizeMarketValue(directValue);
if (direct != null) return direct;
const mirror = normalizeMarketValue(mirrorValue);
if (mirror == null) return null;
return Math.max(0, Math.min(1, 1 - mirror));
}
function formatBucketLabel(
bucket?: {
label?: string | null;
@@ -128,62 +98,6 @@ function formatBucketLabel(
return "--";
}
function parseMetarSignedInt(token: string) {
if (!token) return null;
const normalized = token.toUpperCase();
if (!/^[M]?\d{2}$/.test(normalized)) return null;
const value = Number(normalized.replace("M", ""));
if (!Number.isFinite(value)) return null;
return normalized.startsWith("M") ? -value : value;
}
function parseMetarTempDew(rawMetar?: string | null) {
const text = String(rawMetar || "").toUpperCase();
if (!text)
return { tempC: null as number | null, dewC: null as number | null };
const match = text.match(/\s(M?\d{2})\/(M?\d{2})(?:\s|$)/);
if (!match)
return { tempC: null as number | null, dewC: null as number | null };
return {
tempC: parseMetarSignedInt(match[1]),
dewC: parseMetarSignedInt(match[2]),
};
}
function estimateHumidityFromTempDew(
tempC?: number | null,
dewC?: number | null,
) {
const t = Number(tempC);
const d = Number(dewC);
if (!Number.isFinite(t) || !Number.isFinite(d)) return null;
const es = Math.exp((17.625 * t) / (243.04 + t));
const ed = Math.exp((17.625 * d) / (243.04 + d));
const rh = (ed / es) * 100;
if (!Number.isFinite(rh)) return null;
return Math.max(0, Math.min(100, rh));
}
function parseVisibilityText(
rawMetar?: string | null,
visibilityMi?: number | null,
) {
const direct = Number(visibilityMi);
if (Number.isFinite(direct)) {
return `${direct} mi`;
}
const text = String(rawMetar || "").toUpperCase();
if (!text) return "--";
if (text.includes("CAVOK")) return ">=6 mi";
const sm = text.match(/\s(\d{1,2}(?:\/\d)?)SM(?:\s|$)/);
if (sm) {
return `${sm[1]} mi`;
}
return "--";
}
function parseBucketBoundaries(
bucket?: {
label?: string | null;
@@ -238,15 +152,6 @@ function parseClockMinutes(value?: string | null) {
return hours * 60 + minutes;
}
function parseVisibilityMiles(value?: string | null) {
const text = String(value || "").trim();
if (!text || text === "--") return null;
const match = text.match(/(\d+(?:\.\d+)?)/);
if (!match) return null;
const numeric = Number(match[1]);
return Number.isFinite(numeric) ? numeric : null;
}
function parseLeadingNumber(value?: string | number | null) {
if (typeof value === "number" && Number.isFinite(value)) return value;
const text = String(value || "").trim();
@@ -256,73 +161,6 @@ function parseLeadingNumber(value?: string | number | null) {
return Number.isFinite(numeric) ? numeric : null;
}
function getCurrentMetricDescriptor(
kind: "humidity" | "dewpoint" | "wind" | "visibility",
value: string,
numeric: number | null,
aux?: number | null,
) {
if (kind === "humidity") {
const percent = numeric != null ? clamp(numeric, 0, 100) : null;
const hint =
percent == null
? "--"
: percent >= 75
? "偏湿"
: percent >= 45
? "适中"
: "偏干";
const tone =
percent == null ? "neutral" : percent >= 75 ? "cyan" : percent >= 45 ? "blue" : "amber";
return { fill: percent, hint, tone, value };
}
if (kind === "dewpoint") {
const spread = aux != null && numeric != null ? aux - numeric : null;
const closeness =
spread != null ? clamp(100 - spread * 12, 0, 100) : null;
const hint =
spread == null
? "--"
: spread <= 2
? "近饱和"
: spread <= 6
? "偏湿"
: "偏干";
const tone =
spread == null ? "neutral" : spread <= 2 ? "cyan" : spread <= 6 ? "blue" : "amber";
return { fill: closeness, hint, tone, value };
}
if (kind === "wind") {
const percent = numeric != null ? clamp((numeric / 25) * 100, 0, 100) : null;
const hint =
numeric == null
? "--"
: numeric >= 18
? "偏强"
: numeric >= 8
? "中等"
: "较弱";
const tone =
numeric == null ? "neutral" : numeric >= 18 ? "amber" : numeric >= 8 ? "blue" : "cyan";
return { fill: percent, hint, tone, value };
}
const percent = numeric != null ? clamp((numeric / 10) * 100, 0, 100) : null;
const hint =
numeric == null
? "--"
: numeric >= 6
? "通透"
: numeric >= 3
? "一般"
: "受限";
const tone =
numeric == null ? "neutral" : numeric >= 6 ? "cyan" : numeric >= 3 ? "blue" : "amber";
return { fill: percent, hint, tone, value };
}
function getTrendMetricVisual(metric: {
label?: string;
value?: string;
@@ -913,23 +751,6 @@ export function FutureForecastModal() {
String(detail.current?.station_name || "").trim() ||
String(detail.risk?.airport || "").trim() ||
noaaStationCode;
const marketMidpoint = formatMarketPercent(
marketScan?.market_price ?? marketScan?.yes_token?.implied_probability,
);
const modelProbability = formatMarketPercent(marketScan?.model_probability);
const marketYesBuy = formatMarketPriceCents(marketScan?.yes_buy);
const marketYesSell = formatMarketPriceCents(marketScan?.yes_sell);
const marketNoBuy = formatMarketPriceCents(
resolveCounterPrice(marketScan?.no_buy, marketScan?.yes_buy),
);
const marketNoSell = formatMarketPriceCents(
resolveCounterPrice(marketScan?.no_sell, marketScan?.yes_sell),
);
const marketEdge = formatSignedPercent(marketScan?.edge_percent);
const marketSpread = formatSpreadPercent(
marketScan?.yes_buy,
marketScan?.yes_sell,
);
const topBucket = Array.isArray(marketScan?.top_buckets)
? [...marketScan.top_buckets]
.map((item) => ({
@@ -950,36 +771,7 @@ export function FutureForecastModal() {
)
.sort((a, b) => b.probability - a.probability)[0]
: null;
const settlementBucketLabel = formatBucketLabel(
marketScan?.temperature_bucket,
);
const hottestBucketLabel = formatBucketLabel(topBucket);
const hottestBucketProb = formatMarketPercent(topBucket?.probability);
const marketSignal = marketScan?.signal_label
? `${marketScan.signal_label}${
marketScan.confidence ? ` / ${marketScan.confidence}` : ""
}`
: "--";
const marketExecutionSummary = (() => {
if (!marketScan?.available) {
return locale === "en-US"
? "Market scan is unavailable right now; keep the trade read anchored to station pace and bracket risk."
: "当前暂时拿不到市场扫描结果,先以站点节奏和边界风险为主。";
}
if (marketScan?.signal_label?.toUpperCase() === "BUY YES") {
return locale === "en-US"
? `Model side is hotter than the tape by ${marketEdge}. If pace stays constructive, the Yes side still has room.`
: `模型侧当前比盘口更热 ${marketEdge},如果盘中节奏不掉,Yes 侧仍有空间。`;
}
if (marketScan?.signal_label?.toUpperCase() === "BUY NO") {
return locale === "en-US"
? `Model side is cooler than the tape by ${marketEdge}. If pace keeps lagging, the No side stays cleaner.`
: `模型侧当前比盘口更冷 ${marketEdge},如果节奏继续落后,No 侧会更干净。`;
}
return locale === "en-US"
? "Model and market are broadly balanced; let pace and boundary risk break the tie."
: "模型和市场当前大体均衡,接下来主要看节奏和边界风险来决定站边。";
})();
const probabilitySummary = (() => {
if (!topProbabilityBucket) {
return locale === "en-US"
@@ -1008,8 +800,8 @@ export function FutureForecastModal() {
const numericEdge = Number(marketScan?.edge_percent);
const hottestMatchesSettlement =
hottestBucketLabel !== "--" &&
settlementBucketLabel !== "--" &&
hottestBucketLabel === settlementBucketLabel;
formatBucketLabel(marketScan?.temperature_bucket) !== "--" &&
hottestBucketLabel === formatBucketLabel(marketScan?.temperature_bucket);
const marketAwareUpperAirCue = useMemo(() => {
if (!isToday || (!upperAirSignal.source && !tafSignal.available)) return null;
@@ -1155,19 +947,6 @@ export function FutureForecastModal() {
upperAirSignal.heating_setup,
upperAirSignal.source,
]);
const metarParsed = parseMetarTempDew(detail.current?.raw_metar);
const fallbackDewpoint =
detail.current?.dewpoint ??
metarParsed.dewC ??
(Array.isArray(detail.hourly_next_48h?.dew_point)
? detail.hourly_next_48h?.dew_point?.[0]
: null);
const fallbackHumidity =
detail.current?.humidity ??
estimateHumidityFromTempDew(
detail.current?.temp ?? metarParsed.tempC,
fallbackDewpoint,
);
const topObservedTemp =
detail.current?.max_so_far != null
? detail.current.max_so_far
@@ -1176,27 +955,6 @@ export function FutureForecastModal() {
detail.current?.temp != null
? `${detail.current.temp}${detail.temp_symbol}`
: "--";
const humidityText =
fallbackHumidity != null ? `${Math.round(fallbackHumidity)}%` : "--";
const dewpointText =
fallbackDewpoint != null
? `${fallbackDewpoint}${detail.temp_symbol}`
: "--";
const windText =
detail.current?.wind_speed_kt != null
? `${detail.current.wind_speed_kt} kt`
: "--";
const visibilityText = parseVisibilityText(
detail.current?.raw_metar,
detail.current?.visibility_mi,
);
const humidityValue =
fallbackHumidity != null ? Math.round(fallbackHumidity) : null;
const windValue =
detail.current?.wind_speed_kt != null
? Number(detail.current.wind_speed_kt)
: null;
const visibilityValue = parseVisibilityMiles(visibilityText);
const daylightProgress = (() => {
const now = parseClockMinutes(detail.current?.obs_time);
const sunrise = parseClockMinutes(detail.forecast?.sunrise);
@@ -1212,60 +970,6 @@ export function FutureForecastModal() {
percent,
};
})();
const currentMetricVisuals = [
{
key: "humidity",
label: locale === "en-US" ? "Humidity" : "湿度",
...getCurrentMetricDescriptor("humidity", humidityText, humidityValue),
},
{
key: "dewpoint",
label: locale === "en-US" ? "Dew Point" : "露点",
...getCurrentMetricDescriptor(
"dewpoint",
dewpointText,
fallbackDewpoint != null ? Number(fallbackDewpoint) : null,
detail.current?.temp != null ? Number(detail.current.temp) : null,
),
},
{
key: "wind",
label: locale === "en-US" ? "Wind" : "风速",
...getCurrentMetricDescriptor("wind", windText, windValue),
},
{
key: "visibility",
label: locale === "en-US" ? "Visibility" : "能见度",
...getCurrentMetricDescriptor("visibility", visibilityText, visibilityValue),
},
];
const tradeMetricVisuals = currentMetricVisuals.filter((metric) =>
["dewpoint", "wind", "humidity"].includes(metric.key),
);
const tradeMetricSummary = (() => {
const dewSpread =
detail.current?.temp != null && fallbackDewpoint != null
? Number(detail.current.temp) - Number(fallbackDewpoint)
: null;
if (dewSpread != null && dewSpread <= 2) {
return locale === "en-US"
? "Near-saturation air is still in play. If cloud builds into the peak window, upside can get capped quickly."
: "露点差已经很窄,空气接近饱和。如果云层在峰值窗口内长起来,上冲会更容易被压住。";
}
if (windValue != null && windValue >= 18) {
return locale === "en-US"
? "Surface wind is still strong. Mixing support is better, but it can also make the tape noisier intraday."
: "近地面风速仍偏强,混合作用会更充分,但盘中波动也会更大。";
}
if (humidityValue != null && humidityValue <= 40) {
return locale === "en-US"
? "Air mass is still relatively dry. Unless cloud/rain arrives, the surface layer is not the main cap right now."
: "空气仍偏干,除非后续云雨快速起来,否则近地面层目前不是主要压温来源。";
}
return locale === "en-US"
? "Surface readings are broadly neutral. Keep the main decision anchored to pace, boundary risk, and the peak window."
: "近地面指标整体偏中性,主判断仍然要看当前节奏、边界风险和峰值窗口。";
})();
const displayedUpperAirSummary =
marketAwareUpperAirCue?.summary || view.front.upperAirSummary;
const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map(
@@ -1281,40 +985,56 @@ export function FutureForecastModal() {
}
: metric,
);
const localizedAiCommentaryLines = useMemo(() => {
const commentary = detail.dynamic_commentary || {};
const headline = String(
locale === "en-US" ? commentary.headline_en || "" : commentary.headline_zh || "",
).trim();
const bullets = (
locale === "en-US" ? commentary.bullets_en : commentary.bullets_zh
) as string[] | null | undefined;
const cleanedBullets = Array.isArray(bullets)
? bullets.map((item) => String(item || "").trim()).filter(Boolean)
: [];
return [headline, ...cleanedBullets].filter(Boolean).slice(0, 3);
}, [detail.dynamic_commentary, locale]);
const todayTradeSummaryLines = useMemo(() => {
if (!isToday) return [] as string[];
if (localizedAiCommentaryLines.length > 0) {
return localizedAiCommentaryLines;
}
const lines: string[] = [];
if (paceView) {
const headline =
paceView.biasTone === "warm"
? locale === "en-US"
? `Intraday pace is running hot by ${paceView.deltaText}; the day high still has room to lean above the base curve.`
: `日内节奏当前偏热 ${paceView.deltaText},日高仍有机会落在基础曲线之上。`
? `Pace is running hot by ${paceView.deltaText}; the day high still leans above the base curve.`
: `节奏偏热 ${paceView.deltaText},日高仍偏向落在基础曲线之上。`
: paceView.biasTone === "cold"
? locale === "en-US"
? `Intraday pace is trailing by ${paceView.deltaText}; higher buckets need more caution.`
: `日内节奏当前落后 ${paceView.deltaText},继续追更高温区间要更谨慎。`
? `Pace is trailing by ${paceView.deltaText}; chasing higher buckets needs caution.`
: `节奏落后 ${paceView.deltaText},继续追更高温区间要更谨慎。`
: locale === "en-US"
? "Intraday pace is still tracking the curve; the next move depends on the peak-window push."
: "日内节奏当前基本贴着曲线运行,下一步主要看峰值窗口还有没有上冲。";
? "Pace is still on curve; the next move depends on the peak-window push."
: "节奏目前贴着曲线,下一步主要看峰值窗口还有没有上冲。";
lines.push(headline);
}
if (boundaryRiskView) {
lines.push(
locale === "en-US"
? `${boundaryRiskView.label}: ${boundaryRiskView.status}. ${boundaryRiskView.note}`
: `${boundaryRiskView.label}${boundaryRiskView.status}${boundaryRiskView.note}`,
? `${boundaryRiskView.label}: ${boundaryRiskView.note}`
: `${boundaryRiskView.label}${boundaryRiskView.note}`,
);
}
if (networkLeadView) {
lines.push(
locale === "en-US"
? `${networkLeadView.label}: ${networkLeadView.status}. ${networkLeadView.note}`
: `${networkLeadView.label}${networkLeadView.status}${networkLeadView.note}`,
? `${networkLeadView.label}: ${networkLeadView.note}`
: `${networkLeadView.label}${networkLeadView.note}`,
);
}
return lines;
}, [boundaryRiskView, isToday, locale, networkLeadView, paceView]);
return lines.slice(0, 3);
}, [boundaryRiskView, isToday, locale, localizedAiCommentaryLines, networkLeadView, paceView]);
return (
<div
@@ -1642,220 +1362,6 @@ export function FutureForecastModal() {
</section>
) : null}
<section className="future-v2-card future-v2-support-card">
<div className="future-v2-card-head">
<h4 className="future-v2-card-title">
{locale === "en-US" ? "Trade-Sensitive Metrics" : "交易关键指标"}
</h4>
<div className="future-v2-card-kicker">
{locale === "en-US"
? "Only the surface readings that still matter"
: "只保留仍会影响峰值判断的近地面项"}
</div>
</div>
<div className="future-v2-pace-summary" style={{ marginTop: "12px" }}>
{tradeMetricSummary}
</div>
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
{tradeMetricVisuals.map((metric) => (
<div key={metric.key} className="future-v2-mini-item future-v2-signal-item">
<div className="future-v2-signal-head">
<span>{metric.label}</span>
<em
className={clsx(
"future-v2-signal-tag",
metric.tone === "cyan" && "cyan",
metric.tone === "blue" && "blue",
metric.tone === "amber" && "amber",
)}
>
{metric.hint}
</em>
</div>
<strong>{metric.value}</strong>
<div className="future-v2-signal-meter">
<div
className={clsx(
"future-v2-signal-fill",
metric.tone === "cyan" && "cyan",
metric.tone === "blue" && "blue",
metric.tone === "amber" && "amber",
)}
style={{
width:
metric.fill != null ? `${metric.fill}%` : "18%",
}}
/>
</div>
</div>
))}
</div>
</section>
<section className="future-v2-card future-v2-market-card future-v2-focus-card">
<div className="future-v2-card-head">
<h4 className="future-v2-card-title">
{locale === "en-US" ? "Market Alignment" : "市场对照"}
</h4>
<div className="future-v2-card-kicker">
{locale === "en-US"
? "Model, tape, and execution in one view"
: "把模型、盘口和执行价放到一张卡里"}
</div>
</div>
<div className="future-v2-market-v3">
{/* Loading Overlay */}
{store.loadingState.marketScan && (
<div className="market-layer-loading-overlay">
<div
className="loading-spinner"
style={{
marginBottom: "8px",
width: "24px",
height: "24px",
borderWidth: "2px",
}}
/>
{locale === "en-US"
? "Crunching Polymarket Edges..."
: "正在计算市场对手盘..."}
</div>
)}
{/* Layer 1: Target & Edge */}
<div className="market-layer-target">
<div className="market-target-header">
<span>
{locale === "en-US"
? "Target Bucket:"
: "结算温度区间:"}
</span>
<strong className="market-target-bucket">
{settlementBucketLabel}
</strong>
</div>
<div className="market-edge-box">
<div className="market-edge-header">
<span>
{locale === "en-US" ? "Current edge" : "当前 edge"}
</span>
<strong
className={clsx(
"market-edge-val",
Number(marketScan?.edge_percent) > 0
? "positive"
: "negative",
)}
>
{marketEdge}
</strong>
</div>
<div className="market-edge-compare">
<div className="edge-stat">
<span className="edge-label">
{locale === "en-US" ? "Model prob" : "模型概率"}
</span>
<span className="edge-value">{modelProbability}</span>
</div>
<div className="edge-stat">
<span className="edge-label">
{locale === "en-US" ? "Market mid" : "市场中位"}
</span>
<span className="edge-value">{marketMidpoint}</span>
</div>
</div>
</div>
</div>
<div className="market-layer-book">
<div className="market-sub-title">
💸 {locale === "en-US" ? "Execution" : "盘口执行"}
</div>
<div className="market-book-row">
<span className="book-label">
{locale === "en-US" ? "Yes bid / ask" : "Yes 买 / 卖"}
</span>
<div className="book-quote">
<strong>{marketYesBuy}</strong>
<span> / {marketYesSell}</span>
</div>
</div>
<div className="market-book-row">
<span className="book-label">
{locale === "en-US" ? "No bid / ask" : "No 买 / 卖"}
</span>
<div className="book-quote">
<strong>{marketNoBuy}</strong>
<span> / {marketNoSell}</span>
</div>
</div>
<div className="market-book-row">
<span className="book-label">
{locale === "en-US" ? "Spread" : "价差"}
</span>
<div className="book-quote">
<strong>{marketSpread}</strong>
</div>
</div>
</div>
{/* Layer 3: Context */}
<div className="market-layer-context">
<div className="market-sub-title">
👀 {locale === "en-US" ? "Market Radar" : "情绪雷达"}
</div>
<div className="market-context-row">
<span>
{locale === "en-US"
? "Top Volume Bucket:"
: "市场当前押注最热:"}
</span>
<strong>
{hottestBucketLabel}{" "}
{hottestBucketProb !== "--"
? `(${hottestBucketProb})`
: ""}
</strong>
</div>
</div>
</div>
<div className="future-v2-market-signal mt-3">
{locale === "en-US" ? "Signal" : "信号"}:{" "}
<strong>{marketSignal}</strong>
</div>
<div className="future-v2-pace-summary" style={{ marginTop: "10px" }}>
{marketExecutionSummary}
</div>
</section>
<section className="future-v2-card">
<h4 className="future-v2-card-title">
{locale === "en-US"
? "Trade Snapshot"
: "交易快照"}
</h4>
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Day high so far" : "日内已见高点"}</span>
<strong>
{topObservedTemp ?? "--"}
{detail.temp_symbol}
</strong>
</div>
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Current obs" : "当前观测"}</span>
<strong>{currentTempText}</strong>
</div>
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Market signal" : "市场信号"}</span>
<strong>{marketSignal}</strong>
</div>
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Current edge" : "当前 edge"}</span>
<strong>{marketEdge}</strong>
</div>
</div>
</section>
</aside>
<main className="future-v2-right">
@@ -1947,18 +1453,11 @@ export function FutureForecastModal() {
{Math.round(view.front.precipMax)}%
</span>
</div>
{todayTradeSummaryLines.length > 0 || view.front.summary ? (
{todayTradeSummaryLines.length > 0 ? (
<div className="future-trend-summary">
{[
...todayTradeSummaryLines,
...(Array.isArray(view.front.summaryLines)
? view.front.summaryLines
: view.front.summary
? [view.front.summary]
: []),
].map((line, index) => (
<div key={`${index}-${line}`}>{line}</div>
))}
{todayTradeSummaryLines.map((line, index) => (
<div key={`${index}-${line}`}>{line}</div>
))}
</div>
) : null}
</div>