feat: implement dashboard types, Polymarket data collection, and decision utilities for AI-driven city weather analysis
This commit is contained in:
@@ -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 today’s 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;
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user