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,