diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 57660fa3..0e27f93d 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -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; diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx index fb99a1c4..1760f84e 100644 --- a/frontend/components/dashboard/OpportunityTable.tsx +++ b/frontend/components/dashboard/OpportunityTable.tsx @@ -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 (
- {formatTradeSide(row, locale)} + {formatTradeSide(row, locale, tempSymbol)} diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index d02d8f6b..557c9d71 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -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 分析会展示在每条机会的展开层中。")}

-
@@ -348,7 +436,11 @@ function ScanAiAnalysisView({ return (
- {row.action || row.target_label || row.market_question || row.id} + + {normalizeTemperatureLabel(row.action || row.target_label, tempSymbol) || + row.market_question || + row.id} + {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(); + const order = ["active", "next", "today", "later", "past"]; + const byPhase = new Map< + string, + { + label: string; + sort: number; + items: Array<{ row: ScanOpportunityRow; meta: ReturnType }>; + } + >(); 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 (
- {groups.map(([date, items]) => ( -
+ {groups.map((group) => ( +
-
{formatShortDate(date, locale)}
+
+
{group.label}
+
+ {locale === "en-US" + ? "Ordered by DEB peak-window countdown" + : "按 DEB 峰值窗口倒计时排序"} +
+
- {locale === "en-US" ? `${items.length} rows` : `${items.length} 条`} + {locale === "en-US" ? `${group.items.length} rows` : `${group.items.length} 条`}
- {items.map((row) => ( + {group.items.map(({ row, meta }) => (
+
+ {meta.title} + {meta.detail} +
- {locale === "en-US" ? "DEB high" : "DEB 预测高点"} ·{" "} - {row.deb_prediction != null - ? formatTemperatureValue(row.deb_prediction, tempSymbol) - : "--"} + {locale === "en-US" ? "DEB high" : "DEB 预测高点"} + + {row.deb_prediction != null + ? formatTemperatureValue(row.deb_prediction, tempSymbol) + : "--"} +
- {row.local_time || "--"} + + {formatShortDate(row.selected_date || row.local_date, locale)} · {row.local_time || "--"} + {phaseMeta.label}
@@ -1026,51 +1154,20 @@ function ScanTerminalScreen() { {userLocalTime} - {isPro ? ( - <> - - - - ) : ( + {isPro ? null : store.proAccess.authenticated ? ( + ) : ( + + + {isEn ? "Sign in" : "登录"} + )}
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 20d9bca1..b6317c72 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -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; diff --git a/src/data_collection/polymarket_readonly.py b/src/data_collection/polymarket_readonly.py index 0bf8f4b4..5c12ca33 100644 --- a/src/data_collection/polymarket_readonly.py +++ b/src/data_collection/polymarket_readonly.py @@ -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,