feat: implement dashboard types, Polymarket data collection, and decision utilities for AI-driven city weather analysis

This commit is contained in:
2569718930@qq.com
2026-04-26 09:40:02 +08:00
parent da2126e33e
commit 0f23d8af9d
6 changed files with 249 additions and 35 deletions
@@ -35,6 +35,12 @@ export function normalizeMarketProbability(value: unknown) {
return numeric;
}
function normalizeQuotePrice(value: unknown) {
const normalized = normalizeMarketProbability(value);
if (normalized == null || normalized <= 0) return null;
return normalized;
}
export function formatMarketPercent(value: number | null, digits = 1) {
if (value == null || !Number.isFinite(value)) return "--";
return `${(value * 100).toFixed(digits)}%`;
@@ -42,6 +48,7 @@ export function formatMarketPercent(value: number | null, digits = 1) {
export function formatMarketCents(value: number | null) {
if (value == null || !Number.isFinite(value)) return "--";
if (value > 0 && value < 0.01) return "<1¢";
return `${Math.round(value * 100)}¢`;
}
@@ -66,7 +73,9 @@ export function normalizeMarketComparableTemp(
export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol = "°C") {
const direct = String(bucket?.label || "").trim();
if (direct) return direct;
if (direct && /[°]?[CF]\b|\d+\s*[+-]?$/i.test(direct) && !/[。紊]/.test(direct)) {
return direct.replace(/\bC\b/g, "°C").replace(/\bF\b/g, "°F");
}
const value = bucket?.temp ?? bucket?.value ?? bucket?.lower ?? null;
const numeric = Number(value);
if (Number.isFinite(numeric)) {
@@ -78,6 +87,53 @@ export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol
return "--";
}
function getBucketAnchor(bucket: MarketTopBucket) {
const anchor = Number(bucket.temp ?? bucket.value ?? bucket.lower);
return Number.isFinite(anchor) ? anchor : null;
}
function getBucketModelProbability(bucket?: MarketTopBucket | null) {
const model = normalizeMarketProbability(bucket?.model_probability);
const probability = normalizeMarketProbability(bucket?.probability);
const market = normalizeMarketProbability(bucket?.market_price);
// Some persisted market_scan payloads from older builds overwrote bucket
// probability with the market price. Treat an exact price clone as missing
// model probability so the caller can fall back to scan.model_probability.
if (
model != null &&
market != null &&
Math.abs(model - market) <= 0.000_001
) {
return null;
}
if (
probability != null &&
market != null &&
Math.abs(probability - market) <= 0.000_001
) {
return null;
}
return model ?? probability;
}
function getMarketSelectedBucket(scan: MarketScan | null | undefined): MarketTopBucket | null {
const selected = scan?.temperature_bucket;
if (!selected) return null;
const value = Number(selected.value);
return {
label: selected.label || selected.bucket || selected.range || null,
value: Number.isFinite(value) ? value : null,
temp: Number.isFinite(value) ? value : null,
unit: selected.unit || null,
probability: selected.probability ?? scan?.model_probability ?? null,
model_probability: selected.probability ?? scan?.model_probability ?? null,
market_price: scan?.market_price ?? null,
yes_buy: scan?.yes_buy ?? null,
yes_sell: scan?.yes_sell ?? null,
slug: scan?.selected_slug ?? scan?.primary_market?.slug ?? null,
};
}
export function pickMarketBucketForWeatherCenter(
scan: MarketScan | null | undefined,
expectedHigh: number | null,
@@ -90,8 +146,18 @@ export function pickMarketBucketForWeatherCenter(
? scan?.top_buckets
: []
) as MarketTopBucket[];
const selectedBucket = getMarketSelectedBucket(scan);
const isReasonableFallback = (bucket: MarketTopBucket | null) => {
if (!bucket) return false;
const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket);
const anchor = getBucketAnchor(bucket);
if (comparable == null || anchor == null) return false;
const unit = String(bucket.unit || "").toUpperCase();
const maxReasonableDelta = unit === "F" ? 16 : 8;
return Math.abs(anchor - comparable) <= maxReasonableDelta;
};
if (!buckets.length || expectedHigh == null || !Number.isFinite(expectedHigh)) {
return null;
return selectedBucket;
}
let nearest: MarketTopBucket | null = null;
@@ -111,15 +177,26 @@ export function pickMarketBucketForWeatherCenter(
) {
return bucket;
}
const anchor = Number(bucket.temp ?? bucket.value ?? bucket.lower);
if (!Number.isFinite(anchor)) continue;
const anchor = getBucketAnchor(bucket);
if (anchor == null) continue;
const delta = Math.abs(anchor - comparable);
if (delta < nearestDelta) {
nearest = bucket;
nearestDelta = delta;
}
}
return nearest;
if (!nearest) return isReasonableFallback(selectedBucket) ? selectedBucket : null;
const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, nearest);
const unit = String(nearest.unit || "").toUpperCase();
const maxReasonableDelta = unit === "F" ? 16 : 8;
if (
comparable != null &&
Number.isFinite(nearestDelta) &&
nearestDelta <= maxReasonableDelta
) {
return nearest;
}
return isReasonableFallback(selectedBucket) ? selectedBucket : null;
}
export function buildMarketDecisionView({
@@ -171,18 +248,42 @@ export function buildMarketDecisionView({
}
const bucket = pickMarketBucketForWeatherCenter(marketScan, expectedHigh, tempSymbol);
const bucketProbability = normalizeMarketProbability(bucket?.probability);
if (!bucket) {
return {
bucketLabel: "--",
confidence: marketScan.confidence || "--",
edgeText: "--",
impliedText: formatMarketPercent(
normalizeMarketProbability(marketScan.market_price) ??
normalizeMarketProbability(marketScan.midpoint) ??
normalizeMarketProbability(marketScan.yes_midpoint),
),
marketUrl: marketScan.market_url || marketScan.primary_market_url || null,
modelText: formatMarketPercent(normalizeMarketProbability(marketScan.model_probability)),
priceText: formatMarketCents(normalizeQuotePrice(marketScan.yes_buy)),
reason: isEn
? "A market was found, but its temperature bucket does not match todays expected high closely enough, so edge is withheld."
: "已找到市场,但温度桶与今日预计高点不够匹配,暂不计算概率差。",
status: "ready",
title: isEn ? "Market bucket needs rematch" : "市场温度桶需重新匹配",
tone: "watch",
};
}
const bucketProbability = getBucketModelProbability(bucket);
const scanProbability = normalizeMarketProbability(marketScan.model_probability);
const modelProbability = bucketProbability ?? scanProbability;
const yesBuy =
normalizeMarketProbability(bucket?.yes_buy) ??
normalizeMarketProbability(bucket?.market_price) ??
normalizeMarketProbability(marketScan.yes_buy) ??
normalizeMarketProbability(marketScan.market_price);
normalizeQuotePrice(bucket?.yes_buy) ??
normalizeQuotePrice(marketScan.yes_buy);
const yesSell =
normalizeMarketProbability(bucket?.yes_sell) ??
normalizeMarketProbability(marketScan.yes_sell);
const implied = yesBuy ?? yesSell ?? null;
normalizeQuotePrice(bucket?.yes_sell) ??
normalizeQuotePrice(marketScan.yes_sell);
const marketMid =
normalizeMarketProbability(bucket?.market_price) ??
normalizeMarketProbability(marketScan.market_price) ??
normalizeMarketProbability(marketScan.midpoint) ??
normalizeMarketProbability(marketScan.yes_midpoint);
const implied = marketMid ?? yesBuy ?? yesSell ?? null;
const edge =
modelProbability != null && implied != null ? modelProbability - implied : null;
const tone =
@@ -216,9 +317,10 @@ export function buildMarketDecisionView({
edgeText: formatSignedMarketPercent(edge),
impliedText: formatMarketPercent(implied),
marketUrl:
bucket?.slug
bucket?.market_url ||
(bucket?.slug
? `https://polymarket.com/market/${bucket.slug}`
: marketScan.market_url || marketScan.primary_market_url || null,
: marketScan.market_url || marketScan.primary_market_url || null),
modelText: formatMarketPercent(modelProbability),
priceText: formatMarketCents(yesBuy),
reason:
@@ -195,6 +195,50 @@ export function normalizeCityKey(value?: string | null) {
.replace(/[\s_-]+/g, "");
}
function getOpportunityCardKey(row: ScanOpportunityRow) {
const city =
normalizeCityKey(row.city) ||
normalizeCityKey(row.city_display_name) ||
normalizeCityKey(row.display_name);
const date = String(row.selected_date || row.local_date || "").trim();
if (city || date) {
return `${city || row.id}:${date || "date-unknown"}`;
}
return row.id;
}
function getOpportunitySortScore(row: ScanOpportunityRow) {
return Number(row.final_score || 0) * 1000 + Number(row.edge_percent || 0);
}
function dedupeOpportunityCards(rows: ScanOpportunityRow[]) {
const bestByCard = new Map<string, ScanOpportunityRow>();
for (const row of rows) {
const key = getOpportunityCardKey(row);
const current = bestByCard.get(key);
if (!current || getOpportunitySortScore(row) > getOpportunitySortScore(current)) {
bestByCard.set(key, row);
}
}
return [...bestByCard.values()];
}
function takeUniqueOpportunityRows(
candidates: ScanOpportunityRow[],
usedCardKeys: Set<string>,
limit: number,
) {
const picked: ScanOpportunityRow[] = [];
for (const row of candidates) {
const key = getOpportunityCardKey(row);
if (usedCardKeys.has(key)) continue;
usedCardKeys.add(key);
picked.push(row);
if (picked.length >= limit) break;
}
return picked;
}
export function prettifyCityName(value?: string | null) {
return String(value || "")
.trim()
@@ -323,25 +367,32 @@ export function getRowDecisionMeta(row: ScanOpportunityRow, locale = "zh-CN") {
export function pickOpportunitySections(rows: ScanOpportunityRow[], locale = "zh-CN") {
const isEn = locale === "en-US";
const top = [...rows]
const usedCardKeys = new Set<string>();
const uniqueRows = dedupeOpportunityCards(rows);
const top = takeUniqueOpportunityRows([...uniqueRows]
.sort((left, right) => {
const scoreDelta = Number(right.final_score || 0) - Number(left.final_score || 0);
if (scoreDelta !== 0) return scoreDelta;
return Number(right.edge_percent || 0) - Number(left.edge_percent || 0);
})
.slice(0, 4);
const peak = rows
.filter((row) => {
}), usedCardKeys, 4);
const peak = takeUniqueOpportunityRows(
uniqueRows.filter((row) => {
const meta = getPeakCountdownMeta(row, locale);
return meta.key === "active" || meta.key === "next";
})
.slice(0, 4);
const model = rows
.filter((row) => Number(row.cluster_model_count || 0) >= 4 || Number(row.consensus_score || 0) >= 0.65)
.slice(0, 4);
const risk = rows
.filter((row) => row.risk_level === "high" || ["veto", "downgrade"].includes(String(row.v4_metar_decision || row.ai_decision || "").toLowerCase()))
.slice(0, 4);
}),
usedCardKeys,
4,
);
const model = takeUniqueOpportunityRows(
uniqueRows.filter((row) => Number(row.cluster_model_count || 0) >= 4 || Number(row.consensus_score || 0) >= 0.65),
usedCardKeys,
4,
);
const risk = takeUniqueOpportunityRows(
uniqueRows.filter((row) => row.risk_level === "high" || ["veto", "downgrade"].includes(String(row.v4_metar_decision || row.ai_decision || "").toLowerCase())),
usedCardKeys,
4,
);
return [
{
key: "top",
@@ -99,6 +99,13 @@ export function useAiCityForecast({
let buffer = "";
let rawStream = "";
let finalPayload: AiCityForecastPayload | null = null;
let latestReadableText = "";
const rememberReadableText = (value?: string | null) => {
const text = String(value || "").trim();
if (text) {
latestReadableText = text;
}
};
const handleBlock = (block: string) => {
const message = parseSseBlock(block);
if (!message || !message.data || typeof message.data !== "object") {
@@ -111,6 +118,7 @@ export function useAiCityForecast({
locale === "en-US" ? data.message_en || "" : data.message_zh || "",
).trim() || String(data.message || "").trim();
if (progressText && !cancelled) {
rememberReadableText(progressText);
setAiForecast((current) =>
current.status === "loading"
? { ...current, streamText: current.streamText || progressText }
@@ -132,6 +140,7 @@ export function useAiCityForecast({
).trim() ||
String(data.final_judgment_zh || data.final_judgment_en || "").trim();
if (previewText && !cancelled) {
rememberReadableText(previewText);
setAiForecast((current) =>
current.status === "loading"
? {
@@ -153,6 +162,7 @@ export function useAiCityForecast({
? "AI has started streaming; parsing the METAR read field…"
: "AI 已开始流式输出,正在解析机场报文字段…"
: "");
rememberReadableText(airportRead || streamingText);
if (!cancelled) {
setAiForecast((current) =>
current.status === "loading"
@@ -184,7 +194,45 @@ export function useAiCityForecast({
handleBlock(buffer);
}
if (!finalPayload) {
throw new Error("AI stream ended before final payload");
const fallbackText =
extractStreamingAirportRead(rawStream, locale) ||
latestReadableText ||
(isEn
? "The AI airport read stream was interrupted after partial output."
: "AI 机场报文解读已输出部分内容,但最终载荷未返回。");
const retryHint = isEn
? "The streaming connection ended before the final structured payload. The partial airport read above is preserved; refresh once if you need the full JSON-backed conclusion."
: "流式连接在最终结构化载荷返回前结束。上方已保留已输出的机场报文解读;如需完整 JSON 结论可刷新一次。";
return {
city_forecast: {
confidence: "low",
final_judgment_en: isEn
? fallbackText
: "Partial AI airport read was preserved after the stream ended early.",
final_judgment_zh: isEn
? "AI 机场报文解读已保留部分输出,但流式连接提前结束。"
: fallbackText,
metar_read_en: isEn ? fallbackText : "",
metar_read_zh: isEn ? "" : fallbackText,
model_cluster_note_en: "",
model_cluster_note_zh: "",
predicted_max: null,
range_high: null,
range_low: null,
reasoning_en: retryHint,
reasoning_zh: retryHint,
risks_en: isEn ? [retryHint] : [],
risks_zh: isEn ? [] : [retryHint],
unit: detail?.temp_symbol || "°C",
},
raw_reason: "AI stream ended before final payload",
reason: retryHint,
reason_en: isEn
? retryHint
: "AI stream ended before the final payload; partial text was preserved.",
reason_zh: isEn ? "AI 流在最终载荷前结束;已保留部分文本。" : retryHint,
status: "partial_stream",
};
}
return finalPayload;
}),
+11 -5
View File
@@ -29,19 +29,25 @@ export function useChart<TType extends ChartType>(
if (disposed) return;
const config = createConfig();
if (chartRef.current) {
chartRef.current.destroy();
chartRef.current = null;
const nextType = (config as { type?: ChartType }).type;
const currentType = chartRef.current
? (chartRef.current.config as { type?: ChartType }).type
: null;
if (chartRef.current && currentType === nextType) {
chartRef.current.data = config.data as ChartInstance<TType>["data"];
chartRef.current.options =
(config.options || {}) as ChartInstance<TType>["options"];
chartRef.current.update("none");
return;
}
chartRef.current?.destroy();
chartRef.current = new Chart(canvas, config);
};
void setupChart();
return () => {
disposed = true;
chartRef.current?.destroy();
chartRef.current = null;
};
}, dependencies);
+3
View File
@@ -315,7 +315,9 @@ export interface MarketTopBucket {
upper?: number | null;
unit?: string | null;
probability?: number | null;
model_probability?: number | null;
market_price?: number | null;
edge_percent?: number | null;
yes_buy?: number | null;
yes_sell?: number | null;
no_buy?: number | null;
@@ -325,6 +327,7 @@ export interface MarketTopBucket {
quote_source?: string | null;
quote_age_ms?: number | null;
slug?: string | null;
market_url?: string | null;
question?: string | null;
is_primary?: boolean;
}
+5 -1
View File
@@ -901,7 +901,11 @@ class PolymarketReadOnlyLayer:
if reference_price is not None:
reference_price = max(0.0, min(1.0, float(reference_price)))
bucket["market_price"] = reference_price
bucket["probability"] = reference_price
# Keep model probability separate from market-implied price.
# Older code overwrote ``probability`` with the quote, which made
# downstream UI compare a market price against itself or display
# stale bucket probabilities as weather probabilities.
bucket.setdefault("model_probability", bucket.get("probability"))
if yes_prices.get("quote_source"):
bucket["quote_source"] = yes_prices.get("quote_source")
if yes_prices.get("quote_age_ms") is not None: