feat: implement Polymarket data collection and dashboard UI for weather market analysis

This commit is contained in:
2569718930@qq.com
2026-04-25 03:59:02 +08:00
parent 0e5a1f2652
commit 77c0597e74
5 changed files with 323 additions and 103 deletions
@@ -9380,6 +9380,7 @@
color: #031411;
font-size: 14px;
font-weight: 900;
text-decoration: none;
box-shadow: 0 10px 24px rgba(14, 185, 109, 0.18);
cursor: pointer;
}
@@ -10216,9 +10217,16 @@
color: #8fa4c3;
}
.root :global(.scan-calendar-subtitle) {
margin-top: 4px;
color: #7f96b6;
font-size: 12px;
font-weight: 700;
}
.root :global(.scan-calendar-grid) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
padding: 14px;
}
@@ -10234,12 +10242,26 @@
.root :global(.scan-calendar-card) {
text-align: left;
padding: 14px;
cursor: pointer;
}
.root :global(.scan-calendar-card.selected) {
box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.42);
}
.root :global(.scan-calendar-card.peak-active) {
border-color: rgba(23, 217, 139, 0.34);
background: linear-gradient(180deg, rgba(12, 42, 38, 0.9), rgba(8, 20, 30, 0.94));
}
.root :global(.scan-calendar-card.peak-next) {
border-color: rgba(94, 234, 212, 0.3);
}
.root :global(.scan-calendar-card.peak-past) {
opacity: 0.78;
}
.root :global(.scan-calendar-city) {
font-size: 16px;
font-weight: 800;
@@ -10248,8 +10270,25 @@
.root :global(.scan-calendar-action) {
margin-top: 8px;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
font-size: 15px;
font-weight: 800;
color: #dcecff;
}
.root :global(.scan-calendar-action span) {
color: #8fa4c3;
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-calendar-action b) {
color: #eef7ff;
font-size: 16px;
font-weight: 900;
}
.root :global(.scan-calendar-action.buy) {
@@ -10264,10 +10303,30 @@
margin-top: 10px;
display: flex;
justify-content: space-between;
gap: 12px;
color: #8fa4c3;
font-size: 13px;
}
.root :global(.scan-calendar-countdown) {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid rgba(94, 234, 212, 0.18);
border-radius: 12px;
background: rgba(20, 184, 166, 0.1);
color: #5fffe8;
font-size: 15px;
font-weight: 900;
}
.root :global(.scan-calendar-countdown small) {
display: block;
margin-top: 4px;
color: #9fb4d2;
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-summary-grid) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -11369,6 +11428,27 @@
color: #426b5f;
}
.root :global(.scan-terminal.light .scan-calendar-subtitle),
.root :global(.scan-terminal.light .scan-calendar-action span),
.root :global(.scan-terminal.light .scan-calendar-countdown small) {
color: #647a98;
}
.root :global(.scan-terminal.light .scan-calendar-action b) {
color: #122033;
}
.root :global(.scan-terminal.light .scan-calendar-countdown) {
border-color: rgba(10, 160, 100, 0.18);
background: rgba(220, 252, 239, 0.68);
color: #087b55;
}
.root :global(.scan-terminal.light .scan-calendar-card.peak-active) {
border-color: rgba(10, 160, 100, 0.34);
background: linear-gradient(180deg, rgba(220, 252, 239, 0.84), rgba(255, 255, 255, 0.86));
}
.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-icon) {
background: rgba(16, 185, 129, 0.16);
color: #069668;
@@ -55,9 +55,10 @@ function formatAction(
) {
const formattedTarget = normalizeTemperatureLabel(row.target_label, tempSymbol);
if (row.action) {
return row.target_label
const normalizedAction = row.target_label
? row.action.replace(String(row.target_label), formattedTarget || String(row.target_label))
: row.action;
return normalizeTemperatureLabel(normalizedAction, tempSymbol);
}
if (row.side === "yes") {
return `${locale === "en-US" ? "Buy Yes" : "买入 Yes"} ${formattedTarget || ""}`.trim();
@@ -131,13 +132,19 @@ function formatQuoteCents(value?: number | null) {
return `${text.replace(/\.0$/, "")}¢`;
}
function formatTradeSide(row: ScanOpportunityRow, locale: string) {
function formatTradeSide(
row: ScanOpportunityRow,
locale: string,
tempSymbol?: string | null,
) {
const side = String(row.side || "").toLowerCase();
if (side === "yes") return "BUY YES";
if (side === "no") return "BUY NO";
if (row.action) {
return String(row.action)
.replace(String(row.target_label || ""), "")
return normalizeTemperatureLabel(
String(row.action).replace(String(row.target_label || ""), ""),
tempSymbol,
)
.replace(/\s+/g, " ")
.trim()
.toUpperCase();
@@ -866,8 +873,8 @@ export const OpportunityTable = React.memo(function OpportunityTable({
) ||
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
(isEn
? `${group.cityName} thesis: validate this ${formatTradeSide(row, locale)} against the full model cluster before sizing.`
: `${group.cityName} thesis:该 ${formatTradeSide(row, locale)} 需要先结合全部模型集群确认,再考虑仓位。`);
? `${group.cityName} thesis: validate this ${formatTradeSide(row, locale, tempSymbol)} against the full model cluster before sizing.`
: `${group.cityName} thesis:该 ${formatTradeSide(row, locale, tempSymbol)} 需要先结合全部模型集群确认,再考虑仓位。`);
const modelSources = formatModelSources(row, tempSymbol);
return (
<div
@@ -880,7 +887,7 @@ export const OpportunityTable = React.memo(function OpportunityTable({
</span>
<span className="scan-opportunity-trade">
<strong className={`scan-opportunity-action ${side === "no" ? "sell" : "buy"}`}>
{formatTradeSide(row, locale)}
{formatTradeSide(row, locale, tempSymbol)}
</strong>
</span>
<span className="scan-opportunity-stat threshold">
@@ -3,10 +3,9 @@
import clsx from "clsx";
import Link from "next/link";
import {
LogIn,
RefreshCw,
Moon,
Search,
Sparkles,
Sun,
UserRound,
} from "lucide-react";
@@ -41,7 +40,10 @@ import type {
ScanTerminalResponse,
} from "@/lib/dashboard-types";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import { formatTemperatureValue } from "@/lib/dashboard-utils";
import {
formatTemperatureValue,
normalizeTemperatureLabel,
} from "@/lib/dashboard-utils";
const DEFAULT_FILTERS: FilterState = {
scan_mode: "tradable",
@@ -94,6 +96,111 @@ function formatShortDate(value?: string | null, locale = "zh-CN") {
: date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
}
function formatCountdownMinutes(value?: number | null, locale = "zh-CN") {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return "--";
const minutes = Math.max(0, Math.round(Math.abs(numeric)));
const hours = Math.floor(minutes / 60);
const remains = minutes % 60;
if (locale === "en-US") {
if (hours <= 0) return `${remains}m`;
if (remains <= 0) return `${hours}h`;
return `${hours}h ${remains}m`;
}
if (hours <= 0) return `${remains} 分钟`;
if (remains <= 0) return `${hours} 小时`;
return `${hours} 小时 ${remains} 分钟`;
}
function getPeakWindowLabel(row: ScanOpportunityRow) {
const direct = String(row.peak_window_label || "").trim();
if (direct) return direct;
const start = String(row.peak_window_start || "").trim();
const end = String(row.peak_window_end || "").trim();
if (start && end) return `${start}-${end}`;
return "--";
}
function getPeakCountdownMeta(row: ScanOpportunityRow, locale = "zh-CN") {
const isEn = locale === "en-US";
const phase = String(row.window_phase || "").toLowerCase();
const startDelta = Number(row.minutes_until_peak_start);
const endDelta = Number(row.minutes_until_peak_end);
const hasStart = Number.isFinite(startDelta);
const hasEnd = Number.isFinite(endDelta);
if (phase === "active_peak" || (hasStart && startDelta <= 0 && hasEnd && endDelta >= 0)) {
return {
key: "active",
groupLabel: isEn ? "Peak window now" : "峰值窗口进行中",
tone: "active",
sort: 0,
title: isEn ? "At peak window" : "已进入峰值窗口",
detail:
hasEnd && endDelta >= 0
? isEn
? `${formatCountdownMinutes(endDelta, locale)} left`
: `剩余 ${formatCountdownMinutes(endDelta, locale)}`
: getPeakWindowLabel(row),
};
}
if (hasStart && startDelta > 0 && startDelta <= 180) {
return {
key: "next",
groupLabel: isEn ? "Next 3 hours" : "未来 3 小时到峰值",
tone: "next",
sort: 1000 + startDelta,
title: isEn
? `${formatCountdownMinutes(startDelta, locale)} to peak`
: `还有 ${formatCountdownMinutes(startDelta, locale)} 到峰值`,
detail: getPeakWindowLabel(row),
};
}
if (hasStart && startDelta > 0 && startDelta <= 1440) {
return {
key: "today",
groupLabel: isEn ? "Later today" : "今日稍后",
tone: "upcoming",
sort: 2000 + startDelta,
title: isEn
? `${formatCountdownMinutes(startDelta, locale)} to peak`
: `还有 ${formatCountdownMinutes(startDelta, locale)} 到峰值`,
detail: getPeakWindowLabel(row),
};
}
if (hasStart && startDelta > 1440) {
return {
key: "later",
groupLabel: isEn ? "Later sessions" : "后续交易时段",
tone: "later",
sort: 3000 + startDelta,
title: isEn
? `${formatCountdownMinutes(startDelta, locale)} to peak`
: `还有 ${formatCountdownMinutes(startDelta, locale)} 到峰值`,
detail: getPeakWindowLabel(row),
};
}
return {
key: "past",
groupLabel: isEn ? "Past peak" : "峰值已过",
tone: "past",
sort: 9000 + Math.abs(startDelta || 0),
title:
hasEnd && endDelta < 0
? isEn
? `Peak passed ${formatCountdownMinutes(endDelta, locale)} ago`
: `峰值已过 ${formatCountdownMinutes(endDelta, locale)}`
: isEn
? "Peak window passed"
: "峰值窗口已过",
detail: getPeakWindowLabel(row),
};
}
function formatUserLocalTime() {
const now = new Date();
return `${String(now.getHours()).padStart(2, "0")}:${String(
@@ -194,18 +301,14 @@ function ScanAiAnalysisView({
response,
rows,
logs,
loading,
error,
locale,
onRunAi,
}: {
response: ScanTerminalResponse | null;
rows: ScanOpportunityRow[];
logs: ScanAiLogEntry[];
loading: boolean;
error?: string | null;
locale: string;
onRunAi: () => void;
}) {
const isEn = locale === "en-US";
const aiScan = response?.ai_scan || null;
@@ -264,25 +367,10 @@ function ScanAiAnalysisView({
: error ||
aiScan?.reason ||
(isEn
? "Click AI Deep Scan after a rule scan to generate city theses and contract decisions."
: "规则扫描后点击 AI 深度扫描,生成每城 thesis 和合约推荐/排除理由。")}
? "V4 analysis appears inside each opportunity row when AI review data is available."
: "当 AI 复核数据可用时,V4 分析会展示在每条机会的展开层中。")}
</p>
</div>
<button
type="button"
className="scan-ai-inline-button"
onClick={onRunAi}
disabled={loading || !rows.length}
>
<Sparkles size={14} className={loading ? "spin" : undefined} />
{loading
? isEn
? "Analyzing..."
: "分析中..."
: isEn
? "Run V4"
: "运行 V4"}
</button>
</section>
<section className="scan-ai-log-panel in-tab">
@@ -348,7 +436,11 @@ function ScanAiAnalysisView({
return (
<div key={row.id} className={`scan-ai-contract ${decision}`}>
<div>
<b>{row.action || row.target_label || row.market_question || row.id}</b>
<b>
{normalizeTemperatureLabel(row.action || row.target_label, tempSymbol) ||
row.market_question ||
row.id}
</b>
<small>
{row.deb_prediction != null
? `DEB ${formatTemperatureValue(row.deb_prediction, tempSymbol)} · `
@@ -440,15 +532,36 @@ function CalendarView({
onSelectRow: (row: ScanOpportunityRow) => void;
}) {
const groups = useMemo(() => {
const byDate = new Map<string, ScanOpportunityRow[]>();
const order = ["active", "next", "today", "later", "past"];
const byPhase = new Map<
string,
{
label: string;
sort: number;
items: Array<{ row: ScanOpportunityRow; meta: ReturnType<typeof getPeakCountdownMeta> }>;
}
>();
rows.forEach((row) => {
const key = String(row.selected_date || row.local_date || "unknown");
const list = byDate.get(key) || [];
list.push(row);
byDate.set(key, list);
const meta = getPeakCountdownMeta(row, locale);
const current = byPhase.get(meta.key) || {
label: meta.groupLabel,
sort: order.indexOf(meta.key) >= 0 ? order.indexOf(meta.key) : order.length,
items: [],
};
current.items.push({ row, meta });
byPhase.set(meta.key, current);
});
return Array.from(byDate.entries()).sort(([left], [right]) => left.localeCompare(right));
}, [rows]);
return Array.from(byPhase.entries())
.sort(([, left], [, right]) => left.sort - right.sort)
.map(([key, group]) => ({
key,
label: group.label,
items: group.items.sort((left, right) => {
if (left.meta.sort !== right.meta.sort) return left.meta.sort - right.meta.sort;
return Number(right.row.edge_percent || 0) - Number(left.row.edge_percent || 0);
}),
}));
}, [locale, rows]);
if (!groups.length) {
return (
@@ -462,20 +575,27 @@ function CalendarView({
return (
<div className="scan-calendar-view">
{groups.map(([date, items]) => (
<section key={date} className="scan-calendar-group">
{groups.map((group) => (
<section key={group.key} className="scan-calendar-group">
<div className="scan-calendar-group-head">
<div className="scan-calendar-date">{formatShortDate(date, locale)}</div>
<div>
<div className="scan-calendar-date">{group.label}</div>
<div className="scan-calendar-subtitle">
{locale === "en-US"
? "Ordered by DEB peak-window countdown"
: "按 DEB 峰值窗口倒计时排序"}
</div>
</div>
<div className="scan-calendar-count">
{locale === "en-US" ? `${items.length} rows` : `${items.length}`}
{locale === "en-US" ? `${group.items.length} rows` : `${group.items.length}`}
</div>
</div>
<div className="scan-calendar-grid">
{items.map((row) => (
{group.items.map(({ row, meta }) => (
<button
key={row.id}
type="button"
className={`scan-calendar-card ${selectedRowId === row.id ? "selected" : ""}`}
className={`scan-calendar-card peak-${meta.tone} ${selectedRowId === row.id ? "selected" : ""}`}
onClick={() => onSelectRow(row)}
>
{(() => {
@@ -490,14 +610,22 @@ function CalendarView({
locale,
)}
</div>
<div className="scan-calendar-countdown">
{meta.title}
<small>{meta.detail}</small>
</div>
<div className="scan-calendar-action">
{locale === "en-US" ? "DEB high" : "DEB 预测高点"} ·{" "}
{row.deb_prediction != null
? formatTemperatureValue(row.deb_prediction, tempSymbol)
: "--"}
<span>{locale === "en-US" ? "DEB high" : "DEB 预测高点"}</span>
<b>
{row.deb_prediction != null
? formatTemperatureValue(row.deb_prediction, tempSymbol)
: "--"}
</b>
</div>
<div className="scan-calendar-meta">
<span>{row.local_time || "--"}</span>
<span>
{formatShortDate(row.selected_date || row.local_date, locale)} · {row.local_time || "--"}
</span>
<span>{phaseMeta.label}</span>
</div>
</>
@@ -1026,51 +1154,20 @@ function ScanTerminalScreen() {
<span className="scan-topbar-time">
{userLocalTime}
</span>
{isPro ? (
<>
<button
type="button"
className="scan-primary-button"
onClick={() => void fetchTerminal(activeFilters, true)}
disabled={loading}
>
<Search size={14} />
{loading
? isEn
? "Scanning..."
: "扫描中..."
: isEn
? "Start Scan"
: "开始扫描"}
</button>
<button
type="button"
className="scan-ai-button"
onClick={() => void runAiReview()}
disabled={aiLoading || loading || !terminalData?.rows.length}
title={isEn ? "Run V4-Flash deep scan" : "运行 V4-Flash 深度扫描"}
>
<Sparkles size={14} className={aiLoading ? "spin" : undefined} />
{aiLoading
? isEn
? "V4..."
: "V4 扫描中"
: isEn
? "AI Deep Scan"
: "AI 深度扫描"}
</button>
</>
) : (
{isPro ? null : store.proAccess.authenticated ? (
<button
type="button"
className="scan-primary-button"
onClick={openScanPaywall}
>
<UserRound size={14} />
{store.proAccess.authenticated
? isEn ? "Upgrade Pro" : "升级 Pro"
: isEn ? "Sign in" : "登录"}
{isEn ? "Upgrade Pro" : "升级 Pro"}
</button>
) : (
<Link href={accountHref} className="scan-primary-button">
<LogIn size={14} />
{isEn ? "Sign in" : "登录"}
</Link>
)}
<button
type="button"
@@ -1087,14 +1184,16 @@ function ScanTerminalScreen() {
{isEn ? "Refresh" : "刷新"}
</button>
) : null}
<Link
href={accountHref}
className="scan-account-button"
aria-label={isEn ? "Account" : "账户"}
title={isEn ? "Account" : "账户"}
>
<UserRound size={15} />
</Link>
{store.proAccess.authenticated ? (
<Link
href={accountHref}
className="scan-account-button"
aria-label={isEn ? "Account" : "账户"}
title={isEn ? "Account" : "账户"}
>
<UserRound size={15} />
</Link>
) : null}
</div>
</div>
+5
View File
@@ -521,6 +521,11 @@ export interface ScanOpportunityRow {
window_phase?: string | null;
window_score?: number | null;
remaining_window_minutes?: number | null;
peak_window_start?: string | null;
peak_window_end?: string | null;
peak_window_label?: string | null;
minutes_until_peak_start?: number | null;
minutes_until_peak_end?: number | null;
liquidity_score?: number | null;
price_usefulness_score?: number | null;
spread_penalty?: number | null;
+35 -6
View File
@@ -2153,7 +2153,8 @@ class PolymarketReadOnlyLayer:
question = str(market.get("question") or market.get("title") or "").strip()
direction = self._extract_market_bucket_direction(market)
bucket_range = self._extract_market_bucket_range(market)
unit = bucket_range[2] if bucket_range else "C"
raw_unit = bucket_range[2] if bucket_range else "C"
unit = "°F" if str(raw_unit).upper().endswith("F") else "°C"
if bucket_range and bucket_range[1] is not None:
return f"{bucket_range[0]:g}-{bucket_range[1]:g}{unit}"
if bucket_temp is not None:
@@ -2520,6 +2521,18 @@ class PolymarketReadOnlyLayer:
peak = context.get("peak") if isinstance(context.get("peak"), dict) else {}
first_h = int(_safe_float(peak.get("first_h")) or 13)
last_h = int(_safe_float(peak.get("last_h")) or 15)
first_minutes = max(0, first_h * 60)
last_minutes = min(23 * 60 + 59, last_h * 60)
display_last_minutes = min(23 * 60 + 59, last_h * 60 + 59)
peak_fields: Dict[str, Any] = {
"peak_window_start": f"{first_h:02d}:00",
"peak_window_end": f"{last_h:02d}:59",
"peak_window_label": f"{first_h:02d}:00-{last_h:02d}:59",
"minutes_until_peak_start": None,
"minutes_until_peak_end": None,
"peak_start_minutes": first_minutes,
"peak_end_minutes": display_last_minutes,
}
target_iso = _extract_iso_date(target_date)
if not local_date or not target_iso:
return {
@@ -2527,6 +2540,7 @@ class PolymarketReadOnlyLayer:
"score": 0.65,
"remaining_minutes": None,
"same_day": True,
**peak_fields,
}
try:
@@ -2537,19 +2551,26 @@ class PolymarketReadOnlyLayer:
except Exception:
diff_days = 0
now_minutes = _parse_hhmm_to_minutes(local_time)
if now_minutes is not None:
peak_fields["minutes_until_peak_start"] = diff_days * 1440 + first_minutes - now_minutes
peak_fields["minutes_until_peak_end"] = diff_days * 1440 + display_last_minutes - now_minutes
if diff_days >= 2:
return {
"phase": "week_ahead",
"score": 0.45,
"remaining_minutes": None,
"remaining_minutes": peak_fields["minutes_until_peak_start"],
"same_day": False,
**peak_fields,
}
if diff_days == 1:
return {
"phase": "tomorrow",
"score": 0.60,
"remaining_minutes": None,
"remaining_minutes": peak_fields["minutes_until_peak_start"],
"same_day": False,
**peak_fields,
}
if diff_days < 0:
return {
@@ -2557,25 +2578,25 @@ class PolymarketReadOnlyLayer:
"score": 0.0,
"remaining_minutes": None,
"same_day": False,
**peak_fields,
}
now_minutes = _parse_hhmm_to_minutes(local_time)
if now_minutes is None:
return {
"phase": "today_default",
"score": 0.65,
"remaining_minutes": None,
"same_day": True,
**peak_fields,
}
first_minutes = max(0, first_h * 60)
last_minutes = min(23 * 60 + 59, last_h * 60)
if now_minutes > last_minutes + 120:
return {
"phase": "post_peak",
"score": 0.50,
"remaining_minutes": 0,
"same_day": True,
**peak_fields,
}
if first_minutes <= now_minutes <= last_minutes + 120:
return {
@@ -2583,6 +2604,7 @@ class PolymarketReadOnlyLayer:
"score": 1.00,
"remaining_minutes": max(0, last_minutes + 120 - now_minutes),
"same_day": True,
**peak_fields,
}
if first_minutes - 180 <= now_minutes < first_minutes:
return {
@@ -2590,12 +2612,14 @@ class PolymarketReadOnlyLayer:
"score": 0.85,
"remaining_minutes": max(0, last_minutes + 120 - now_minutes),
"same_day": True,
**peak_fields,
}
return {
"phase": "early_today",
"score": 0.70,
"remaining_minutes": max(0, first_minutes - now_minutes),
"same_day": True,
**peak_fields,
}
def _resolve_market_target_threshold(
@@ -3198,6 +3222,11 @@ class PolymarketReadOnlyLayer:
"window_phase": window_meta.get("phase"),
"window_score": window_meta.get("score"),
"remaining_window_minutes": window_meta.get("remaining_minutes"),
"peak_window_start": window_meta.get("peak_window_start"),
"peak_window_end": window_meta.get("peak_window_end"),
"peak_window_label": window_meta.get("peak_window_label"),
"minutes_until_peak_start": window_meta.get("minutes_until_peak_start"),
"minutes_until_peak_end": window_meta.get("minutes_until_peak_end"),
"liquidity_score": liquidity_score,
"price_usefulness_score": price_usefulness_score,
"spread_penalty": spread_penalty,