feat: implement Polymarket data collection and dashboard UI for weather market analysis
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user