feat: implement ScanTerminalDashboard with modular components and backend integration for Polymarket data collection
This commit is contained in:
@@ -41,6 +41,8 @@ frontend/.next/
|
||||
frontend/.vercel/
|
||||
frontend/*.tsbuildinfo
|
||||
frontend/.codex-next-dev*.log
|
||||
frontend/.codex-next-start*.log
|
||||
.codex-backend-*.log
|
||||
|
||||
.npm-cache/
|
||||
.codex-tmp/
|
||||
|
||||
@@ -9781,7 +9781,10 @@
|
||||
|
||||
.root :global(.scan-opportunity-item) {
|
||||
display: grid;
|
||||
grid-template-columns: 16px minmax(180px, 1fr) max-content max-content max-content;
|
||||
grid-template-columns:
|
||||
16px minmax(96px, 0.7fr) minmax(82px, 0.45fr)
|
||||
minmax(114px, 0.52fr) minmax(96px, 0.46fr)
|
||||
minmax(92px, 0.42fr) max-content max-content;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
@@ -9792,7 +9795,7 @@
|
||||
background: rgba(4, 12, 22, 0.48);
|
||||
color: #b8c9e1;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
@@ -9916,6 +9919,10 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-stat.threshold) {
|
||||
min-width: 82px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-stat b.positive) {
|
||||
color: #1de28f;
|
||||
}
|
||||
@@ -9961,6 +9968,160 @@
|
||||
color: #ff8585;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-ai.neutral b) {
|
||||
color: #9fc5ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-strength) {
|
||||
display: inline-flex;
|
||||
justify-self: end;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 66px;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(90, 123, 166, 0.16);
|
||||
background: rgba(13, 28, 48, 0.6);
|
||||
color: #91a7c4;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-strength.strong) {
|
||||
color: #ff8585;
|
||||
border-color: rgba(248, 113, 113, 0.32);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-strength.medium) {
|
||||
color: #ffd166;
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-strength.watch) {
|
||||
color: #7aa8ff;
|
||||
border-color: rgba(96, 165, 250, 0.28);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-expand) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(65, 118, 216, 0.34);
|
||||
border-radius: 9px;
|
||||
background: rgba(10, 30, 58, 0.8);
|
||||
color: #7fb4ff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-expand:hover) {
|
||||
border-color: rgba(96, 165, 250, 0.5);
|
||||
background: rgba(20, 47, 86, 0.86);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-expand:focus-visible) {
|
||||
outline: 2px solid rgba(34, 211, 238, 0.72);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis) {
|
||||
grid-column: 2 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(69, 116, 178, 0.18);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(9, 23, 38, 0.9), rgba(6, 16, 28, 0.92)),
|
||||
radial-gradient(circle at 0 0, rgba(34, 211, 238, 0.1), transparent 30%);
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis section) {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(90, 123, 166, 0.12);
|
||||
border-radius: 10px;
|
||||
background: rgba(6, 16, 28, 0.52);
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis section:first-child),
|
||||
.root :global(.scan-v4-analysis .scan-v4-evidence) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis strong) {
|
||||
display: block;
|
||||
color: #72f3d1;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis p) {
|
||||
margin: 7px 0 0;
|
||||
color: #c2d2e7;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis ul) {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 8px 0 0;
|
||||
padding-left: 17px;
|
||||
color: #aabdd6;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-evidence > div) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-evidence > div > span),
|
||||
.root :global(.scan-v4-model-sources span) {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
min-height: 28px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid rgba(122, 154, 196, 0.14);
|
||||
border-radius: 8px;
|
||||
background: rgba(13, 28, 48, 0.58);
|
||||
color: #d4e4f8;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-model-sources em) {
|
||||
color: #839abd;
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-model-sources b) {
|
||||
color: #eef7ff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-body::-webkit-scrollbar),
|
||||
.root :global(.scan-calendar-view::-webkit-scrollbar),
|
||||
.root :global(.scan-detail-panel::-webkit-scrollbar) {
|
||||
@@ -10399,6 +10560,48 @@
|
||||
radial-gradient(circle at 0 0, rgba(34, 211, 238, 0.12), transparent 34%);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-panel.compact) {
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
background: rgba(7, 17, 30, 0.72);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
color: #9fb4cf;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary::-webkit-details-marker) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary span) {
|
||||
color: #c7d8ef;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary strong) {
|
||||
color: #7fb4ff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-panel.compact[open] .scan-ai-log-list) {
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-analysis-view) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -11289,11 +11492,60 @@
|
||||
color: #647a98;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-opportunity-strength) {
|
||||
border-color: rgba(35, 72, 118, 0.12);
|
||||
background: rgba(248, 252, 255, 0.78);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-opportunity-expand) {
|
||||
color: #1d4ed8;
|
||||
border-color: rgba(37, 99, 235, 0.18);
|
||||
background: rgba(239, 246, 255, 0.84);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-v4-analysis),
|
||||
.root :global(.scan-terminal.light .scan-v4-analysis section),
|
||||
.root :global(.scan-terminal.light .scan-v4-evidence > div > span),
|
||||
.root :global(.scan-terminal.light .scan-v4-model-sources span) {
|
||||
border-color: rgba(35, 72, 118, 0.12);
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-v4-analysis strong) {
|
||||
color: #057a6b;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-v4-analysis p),
|
||||
.root :global(.scan-terminal.light .scan-v4-analysis ul),
|
||||
.root :global(.scan-terminal.light .scan-v4-evidence > div > span) {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-v4-model-sources em) {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-v4-model-sources b) {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-ai-log-panel) {
|
||||
border-color: rgba(35, 72, 118, 0.12);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(246, 250, 255, 0.9));
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-ai-log-panel.compact) {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-ai-log-panel.compact .scan-ai-log-summary span) {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-ai-log-panel.compact .scan-ai-log-summary strong) {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-ai-log-head strong),
|
||||
.root :global(.scan-terminal.light .scan-ai-log-item b) {
|
||||
color: #122033;
|
||||
@@ -11460,6 +11712,15 @@
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-ai),
|
||||
.root :global(.scan-v4-analysis) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-cards) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import type {
|
||||
@@ -130,6 +131,243 @@ function formatQuoteCents(value?: number | null) {
|
||||
return `${text.replace(/\.0$/, "")}¢`;
|
||||
}
|
||||
|
||||
function formatTradeSide(row: ScanOpportunityRow, locale: string) {
|
||||
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 || ""), "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
return locale === "en-US" ? "WATCH" : "观察";
|
||||
}
|
||||
|
||||
function formatThreshold(row: ScanOpportunityRow, tempSymbol?: string | null) {
|
||||
const targetLabel = normalizeTemperatureLabel(row.target_label, tempSymbol);
|
||||
if (targetLabel) return targetLabel;
|
||||
if (row.target_lower != null && row.target_upper != null) {
|
||||
return `${formatTemperatureValue(Number(row.target_lower), tempSymbol)} ~ ${formatTemperatureValue(Number(row.target_upper), tempSymbol)}`;
|
||||
}
|
||||
if (row.target_threshold != null) {
|
||||
return formatTemperatureValue(Number(row.target_threshold), tempSymbol);
|
||||
}
|
||||
if (row.target_value != null) {
|
||||
return formatTemperatureValue(Number(row.target_value), tempSymbol);
|
||||
}
|
||||
return "--";
|
||||
}
|
||||
|
||||
function getOpportunityStrength(edgePercent?: number | null, locale = "zh-CN") {
|
||||
const edge = Number(edgePercent);
|
||||
const normalized = Number.isFinite(edge) ? edge : 0;
|
||||
if (normalized >= 20) {
|
||||
return {
|
||||
label: locale === "en-US" ? "Strong" : "强机会",
|
||||
tone: "strong",
|
||||
};
|
||||
}
|
||||
if (normalized >= 10) {
|
||||
return {
|
||||
label: locale === "en-US" ? "Medium" : "中机会",
|
||||
tone: "medium",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: locale === "en-US" ? "Watch" : "观察",
|
||||
tone: "watch",
|
||||
};
|
||||
}
|
||||
|
||||
function getLocalizedRowText(
|
||||
row: ScanOpportunityRow,
|
||||
locale: string,
|
||||
zh?: string | null,
|
||||
en?: string | null,
|
||||
) {
|
||||
return locale === "en-US" ? en || zh || null : zh || en || null;
|
||||
}
|
||||
|
||||
function formatModelSources(row: ScanOpportunityRow, tempSymbol?: string | null) {
|
||||
const sources = row.model_cluster_sources || {};
|
||||
return Object.entries(sources)
|
||||
.filter(([, value]) => value != null && Number.isFinite(Number(value)))
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([name, value]) => ({
|
||||
name,
|
||||
value: formatTemperatureValue(Number(value), tempSymbol, { digits: 1 }),
|
||||
}));
|
||||
}
|
||||
|
||||
function getModelSourceSummary(
|
||||
row: ScanOpportunityRow,
|
||||
locale: string,
|
||||
tempSymbol?: string | null,
|
||||
) {
|
||||
const sources = formatModelSources(row, tempSymbol);
|
||||
if (!sources.length) {
|
||||
return locale === "en-US"
|
||||
? "model cluster pending"
|
||||
: "模型集群暂未回传";
|
||||
}
|
||||
const shown = sources.map((item) => `${item.name} ${item.value}`).join(" / ");
|
||||
return locale === "en-US"
|
||||
? `all models: ${shown}`
|
||||
: `全部模型:${shown}`;
|
||||
}
|
||||
|
||||
function getShortAiConclusion(
|
||||
row: ScanOpportunityRow,
|
||||
locale: string,
|
||||
edgePercent?: number | null,
|
||||
strengthLabel?: string,
|
||||
) {
|
||||
const directReason =
|
||||
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
|
||||
getLocalizedRowText(
|
||||
row,
|
||||
locale,
|
||||
row.ai_watchlist_reason_zh,
|
||||
row.ai_watchlist_reason_en,
|
||||
);
|
||||
if (directReason) return directReason;
|
||||
const cityThesis = getLocalizedRowText(
|
||||
row,
|
||||
locale,
|
||||
row.ai_city_thesis_zh,
|
||||
row.ai_city_thesis_en,
|
||||
);
|
||||
if (cityThesis) return cityThesis;
|
||||
|
||||
const edgeText = formatPercent(edgePercent, true);
|
||||
const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
|
||||
if (locale === "en-US") {
|
||||
return `${strengthLabel || "Watch"} setup: edge ${edgeText}; V4 should validate against ${modelBasis}.`;
|
||||
}
|
||||
return `${strengthLabel || "观察"}:edge ${edgeText},V4 需结合${modelBasis}确认。`;
|
||||
}
|
||||
|
||||
function getRiskHints(
|
||||
row: ScanOpportunityRow,
|
||||
locale: string,
|
||||
modelProbability?: number | null,
|
||||
) {
|
||||
const hints: string[] = [];
|
||||
const spread = Number(row.spread);
|
||||
if (Number.isFinite(spread) && spread > 0.03) {
|
||||
hints.push(
|
||||
locale === "en-US"
|
||||
? `Wide spread ${formatQuoteCents(spread)} may distort executable edge.`
|
||||
: `盘口价差 ${formatQuoteCents(spread)} 偏宽,可能扭曲可执行 edge。`,
|
||||
);
|
||||
}
|
||||
const quoteAgeSeconds =
|
||||
row.quote_age_ms != null && Number.isFinite(Number(row.quote_age_ms))
|
||||
? Math.round(Number(row.quote_age_ms) / 1000)
|
||||
: null;
|
||||
if (quoteAgeSeconds != null && quoteAgeSeconds > 60) {
|
||||
hints.push(
|
||||
locale === "en-US"
|
||||
? `Quote age ${quoteAgeSeconds}s; refresh before acting.`
|
||||
: `报价已 ${quoteAgeSeconds}s,执行前需要刷新。`,
|
||||
);
|
||||
}
|
||||
if (row.trend_alignment === false) {
|
||||
hints.push(
|
||||
locale === "en-US"
|
||||
? "Intraday trend does not fully support this direction."
|
||||
: "日内趋势未完全支持该方向。",
|
||||
);
|
||||
}
|
||||
if (row.cluster_adjusted) {
|
||||
hints.push(
|
||||
locale === "en-US"
|
||||
? "Tail bucket was cluster-adjusted; raw edge may be overstated."
|
||||
: "尾部桶已做模型集群折扣,原始 edge 可能偏乐观。",
|
||||
);
|
||||
}
|
||||
if (modelProbability != null && modelProbability < 10) {
|
||||
hints.push(
|
||||
locale === "en-US"
|
||||
? "Low model probability makes the setup sensitive to calibration error."
|
||||
: "模型概率偏低,校准误差会显著影响判断。",
|
||||
);
|
||||
}
|
||||
if (!hints.length) {
|
||||
hints.push(
|
||||
locale === "en-US"
|
||||
? "Main residual risk is late observation updates or a shifted peak window."
|
||||
: "主要残余风险是后续实测升温或峰值窗口漂移。",
|
||||
);
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
function getRecommendationReasons(
|
||||
row: ScanOpportunityRow,
|
||||
locale: string,
|
||||
modelProbability?: number | null,
|
||||
edgePercent?: number | null,
|
||||
price?: number | null,
|
||||
) {
|
||||
const reasons: string[] = [];
|
||||
const aiReason = getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en);
|
||||
if (aiReason && String(row.ai_decision || "").toLowerCase() === "approve") {
|
||||
reasons.push(aiReason);
|
||||
}
|
||||
reasons.push(
|
||||
locale === "en-US"
|
||||
? `EMOS probability ${formatPercent(modelProbability)} vs market price ${formatQuoteCents(price)} gives edge ${formatPercent(edgePercent, true)}.`
|
||||
: `EMOS 概率 ${formatPercent(modelProbability)} 对比市场价格 ${formatQuoteCents(price)},edge ${formatPercent(edgePercent, true)}。`,
|
||||
);
|
||||
if (row.peak_alignment_score != null) {
|
||||
reasons.push(
|
||||
locale === "en-US"
|
||||
? `Peak alignment score ${Number(row.peak_alignment_score).toFixed(2)} supports checking this bucket.`
|
||||
: `峰值对齐分 ${Number(row.peak_alignment_score).toFixed(2)},支持把该桶纳入检查。`,
|
||||
);
|
||||
}
|
||||
return reasons.slice(0, 3);
|
||||
}
|
||||
|
||||
function getExclusionReasons(
|
||||
row: ScanOpportunityRow,
|
||||
locale: string,
|
||||
edgePercent?: number | null,
|
||||
) {
|
||||
const decision = String(row.ai_decision || "").toLowerCase();
|
||||
const aiReason =
|
||||
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
|
||||
getLocalizedRowText(
|
||||
row,
|
||||
locale,
|
||||
row.ai_watchlist_reason_zh,
|
||||
row.ai_watchlist_reason_en,
|
||||
);
|
||||
if (decision === "veto" || decision === "downgrade" || decision === "watchlist") {
|
||||
return [
|
||||
aiReason ||
|
||||
(locale === "en-US"
|
||||
? "V4 did not classify this row as a primary recommendation."
|
||||
: "V4 未把该合约列为主推荐。"),
|
||||
];
|
||||
}
|
||||
if (edgePercent != null && Number(edgePercent) < 10) {
|
||||
return [
|
||||
locale === "en-US"
|
||||
? "Edge is below the medium-opportunity threshold."
|
||||
: "edge 低于中机会阈值。",
|
||||
];
|
||||
}
|
||||
return [
|
||||
locale === "en-US"
|
||||
? "No hard veto in the current V4/rule snapshot."
|
||||
: "当前 V4/规则快照没有硬性排除项。",
|
||||
];
|
||||
}
|
||||
|
||||
function getAiMeta(row: ScanOpportunityRow, locale: string) {
|
||||
const decision = String(row.ai_decision || "").toLowerCase();
|
||||
if (decision === "veto") {
|
||||
@@ -463,6 +701,21 @@ export const OpportunityTable = React.memo(function OpportunityTable({
|
||||
() => buildOpportunityGroups(rows, locale, cityDetailsByName),
|
||||
[rows, locale, cityDetailsByName],
|
||||
);
|
||||
const [expandedRowIds, setExpandedRowIds] = React.useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const toggleExpandedRow = React.useCallback((rowId: string) => {
|
||||
setExpandedRowIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(rowId)) {
|
||||
next.delete(rowId);
|
||||
} else {
|
||||
next.add(rowId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!hasRows) {
|
||||
const title =
|
||||
@@ -524,6 +777,14 @@ export const OpportunityTable = React.memo(function OpportunityTable({
|
||||
<div className="scan-opportunity-city">
|
||||
<strong>{group.cityName}</strong>
|
||||
<div className="scan-opportunity-models">
|
||||
<span>
|
||||
<em>{isEn ? "Local time" : "当前时间"}</em>
|
||||
<b>{group.localTime || "--"}</b>
|
||||
</span>
|
||||
<span>
|
||||
<em>{isEn ? "Settlement left" : "剩余结算时间"}</em>
|
||||
<b>{formatWindowMinutes(group.remainingMinutes, locale)}</b>
|
||||
</span>
|
||||
<span>
|
||||
<em>DEB</em>
|
||||
<b>{group.debLabel}</b>
|
||||
@@ -541,16 +802,14 @@ export const OpportunityTable = React.memo(function OpportunityTable({
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-opportunity-phase">
|
||||
<span>{group.localTime || "--"}</span>
|
||||
<b className={`scan-phase-badge ${group.phaseMeta.tone}`}>
|
||||
{group.phaseMeta.label}
|
||||
</b>
|
||||
<em>{formatWindowMinutes(group.remainingMinutes, locale)}</em>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="scan-opportunity-items">
|
||||
{group.rows.map((row, rowIndex) => {
|
||||
{group.rows.map((row) => {
|
||||
const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
|
||||
const side = String(row.side || "").toLowerCase();
|
||||
const detail = getDetailForRow(row, cityDetailsByName);
|
||||
@@ -577,23 +836,57 @@ export const OpportunityTable = React.memo(function OpportunityTable({
|
||||
modelProbability != null && row.ask != null
|
||||
? modelProbability - Number(row.ask) * 100
|
||||
: row.edge_percent;
|
||||
const modelLabel = "EMOS";
|
||||
const priceLabel = side === "no" ? "NO" : isEn ? "Market" : "市场";
|
||||
const modelLabel = isEn ? "EMOS prob" : "EMOS 概率";
|
||||
const priceLabel = isEn ? "Market price" : "市场价格";
|
||||
const edgePositive = Number(edgePercent || 0) >= 0;
|
||||
const aiMeta = getAiMeta(row, locale);
|
||||
const strength = getOpportunityStrength(edgePercent, locale);
|
||||
const expanded = expandedRowIds.has(row.id);
|
||||
const shortConclusion = getShortAiConclusion(
|
||||
row,
|
||||
locale,
|
||||
edgePercent,
|
||||
strength.label,
|
||||
);
|
||||
const recommendationReasons = getRecommendationReasons(
|
||||
row,
|
||||
locale,
|
||||
modelProbability,
|
||||
edgePercent,
|
||||
row.ask,
|
||||
);
|
||||
const exclusionReasons = getExclusionReasons(row, locale, edgePercent);
|
||||
const riskHints = getRiskHints(row, locale, modelProbability);
|
||||
const thesis =
|
||||
getLocalizedRowText(
|
||||
row,
|
||||
locale,
|
||||
row.ai_city_thesis_zh,
|
||||
row.ai_city_thesis_en,
|
||||
) ||
|
||||
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)} 需要先结合全部模型集群确认,再考虑仓位。`);
|
||||
const modelSources = formatModelSources(row, tempSymbol);
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className={`scan-opportunity-item ${aiMeta ? `ai-${aiMeta.tone}` : ""}`}
|
||||
className={`scan-opportunity-item ${selectedRowId === row.id ? "selected" : ""} ${aiMeta ? `ai-${aiMeta.tone}` : ""}`}
|
||||
onClick={() => onSelectRow?.(row)}
|
||||
>
|
||||
<span className="scan-opportunity-branch" aria-hidden="true">
|
||||
<i />
|
||||
</span>
|
||||
<span className="scan-opportunity-trade">
|
||||
<strong className={`scan-opportunity-action ${side === "no" ? "sell" : "buy"}`}>
|
||||
{formatAction(row, locale, tempSymbol)}
|
||||
{formatTradeSide(row, locale)}
|
||||
</strong>
|
||||
</span>
|
||||
<span className="scan-opportunity-stat threshold">
|
||||
<small>{isEn ? "Threshold" : "阈值"}</small>
|
||||
<b>{formatThreshold(row, tempSymbol)}</b>
|
||||
</span>
|
||||
<span className="scan-opportunity-stat">
|
||||
<small>{modelLabel}</small>
|
||||
<b>{formatPercent(modelProbability)}</b>
|
||||
@@ -608,11 +901,103 @@ export const OpportunityTable = React.memo(function OpportunityTable({
|
||||
{formatPercent(edgePercent, true)}
|
||||
</b>
|
||||
</span>
|
||||
{aiMeta ? (
|
||||
<span className={`scan-opportunity-ai ${aiMeta.tone}`}>
|
||||
<b>{aiMeta.label}</b>
|
||||
{aiMeta.reason ? <small>{aiMeta.reason}</small> : null}
|
||||
</span>
|
||||
<span className={`scan-opportunity-strength ${strength.tone}`}>
|
||||
{strength.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="scan-opportunity-expand"
|
||||
aria-expanded={expanded}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleExpandedRow(row.id);
|
||||
onSelectRow?.(row);
|
||||
}}
|
||||
>
|
||||
<BarChart3 size={14} />
|
||||
{expanded
|
||||
? isEn
|
||||
? "Hide analysis"
|
||||
: "收起分析"
|
||||
: isEn
|
||||
? "Full analysis"
|
||||
: "查看完整分析"}
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
<span className={`scan-opportunity-ai ${aiMeta?.tone || "neutral"}`}>
|
||||
<b>{isEn ? "AI take" : "AI 结论"}</b>
|
||||
<small>{shortConclusion}</small>
|
||||
</span>
|
||||
{expanded ? (
|
||||
<div className="scan-v4-analysis">
|
||||
<section>
|
||||
<strong>thesis</strong>
|
||||
<p>{thesis}</p>
|
||||
</section>
|
||||
<section>
|
||||
<strong>{isEn ? "Recommendation reasons" : "推荐理由"}</strong>
|
||||
<ul>
|
||||
{recommendationReasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<strong>{isEn ? "Exclusion reasons" : "排除理由"}</strong>
|
||||
<ul>
|
||||
{exclusionReasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<strong>{isEn ? "Risk notes" : "风险提示"}</strong>
|
||||
<ul>
|
||||
{riskHints.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section className="scan-v4-evidence">
|
||||
<strong>{isEn ? "Data basis" : "数据依据"}</strong>
|
||||
<div>
|
||||
<span>DEB {group.debLabel}</span>
|
||||
<span>EMOS peak {group.peakLabel}</span>
|
||||
<span>
|
||||
{isEn ? "Peak prob" : "峰值概率"}{" "}
|
||||
{formatPercent(group.peakProbability)}
|
||||
</span>
|
||||
<span>
|
||||
{isEn ? "Ask" : "买价"} {formatQuoteCents(row.ask)}
|
||||
</span>
|
||||
<span>edge {formatPercent(edgePercent, true)}</span>
|
||||
{row.kelly_fraction != null ? (
|
||||
<span>
|
||||
Kelly {formatPercent(Number(row.kelly_fraction) * 100)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="scan-v4-model-sources">
|
||||
{modelSources.length ? (
|
||||
modelSources.map((source) => (
|
||||
<span key={source.name}>
|
||||
<em>{source.name}</em>
|
||||
<b>{source.value}</b>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span>
|
||||
<em>{isEn ? "Models" : "模型"}</em>
|
||||
<b>
|
||||
{isEn
|
||||
? "waiting for cluster"
|
||||
: "等待模型集群"}
|
||||
</b>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1195,17 +1195,6 @@ export function ProbabilityDistribution({
|
||||
const probability = Math.round(
|
||||
Number(row.probability || 0) * 100,
|
||||
);
|
||||
const rowMarketBucket = row.marketBucket;
|
||||
const rowMarketPrice =
|
||||
rowMarketBucket?.yes_buy ??
|
||||
rowMarketBucket?.market_price ??
|
||||
null;
|
||||
const yesPriceText = toPriceCents(rowMarketPrice);
|
||||
const marketTagFinal = rowMarketBucket
|
||||
? locale === "en-US"
|
||||
? `YES ask: ${yesPriceText || "--"}`
|
||||
: `YES 买价: ${yesPriceText || "--"}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={`${row.key || index}`} className="prob-row">
|
||||
@@ -1218,16 +1207,6 @@ export function ProbabilityDistribution({
|
||||
{probability}%
|
||||
</div>
|
||||
</div>
|
||||
{marketTagFinal && (
|
||||
<div
|
||||
className={clsx(
|
||||
"prob-market-inline",
|
||||
rowMarketBucket ? "yes" : "no",
|
||||
)}
|
||||
>
|
||||
{marketTagFinal}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -59,7 +59,7 @@ const SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000;
|
||||
|
||||
interface FilterState extends ScanTerminalFilters {}
|
||||
|
||||
type ContentView = "list" | "map" | "ai" | "calendar";
|
||||
type ContentView = "list" | "map" | "calendar";
|
||||
type ThemeMode = "dark" | "light";
|
||||
type ScanAiLogTone = "info" | "success" | "warning" | "error";
|
||||
|
||||
@@ -370,6 +370,64 @@ function ScanAiAnalysisView({
|
||||
);
|
||||
}
|
||||
|
||||
function ScanAiLogPanel({
|
||||
response,
|
||||
logs,
|
||||
locale,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
response: ScanTerminalResponse | null;
|
||||
logs: ScanAiLogEntry[];
|
||||
locale: string;
|
||||
loading: boolean;
|
||||
error?: string | null;
|
||||
}) {
|
||||
const isEn = locale === "en-US";
|
||||
const aiScan = response?.ai_scan || null;
|
||||
return (
|
||||
<details className="scan-ai-log-panel compact">
|
||||
<summary className="scan-ai-log-summary">
|
||||
<span>{isEn ? "Debug log" : "调试日志"}</span>
|
||||
<strong>
|
||||
{loading
|
||||
? isEn
|
||||
? "V4 running"
|
||||
: "V4 运行中"
|
||||
: error
|
||||
? isEn
|
||||
? "V4 failed"
|
||||
: "V4 失败"
|
||||
: aiScan?.status === "ready"
|
||||
? isEn
|
||||
? `${aiScan.sent_cities ?? "--"} cities · ${aiScan.sent_contracts ?? aiScan.sent_rows ?? "--"} contracts`
|
||||
: `${aiScan.sent_cities ?? "--"} 城 · ${aiScan.sent_contracts ?? aiScan.sent_rows ?? "--"} 合约`
|
||||
: isEn
|
||||
? "Collapsed"
|
||||
: "已折叠"}
|
||||
</strong>
|
||||
</summary>
|
||||
<div className="scan-ai-log-list">
|
||||
{logs.length ? (
|
||||
logs.map((entry) => (
|
||||
<div key={entry.id} className={`scan-ai-log-item ${entry.tone}`}>
|
||||
<span className="scan-ai-log-time">{entry.time}</span>
|
||||
<div>
|
||||
<b>{entry.title}</b>
|
||||
{entry.detail ? <small>{entry.detail}</small> : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="scan-ai-log-empty">
|
||||
{isEn ? "No request has been sent." : "还没有发起请求。"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarView({
|
||||
rows,
|
||||
locale,
|
||||
@@ -469,7 +527,7 @@ function ScanTerminalScreen() {
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
||||
const [activeView, setActiveView] = useState<ContentView>("map");
|
||||
const [activeView, setActiveView] = useState<ContentView>("list");
|
||||
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
||||
const [showScanPaywall, setShowScanPaywall] = useState(false);
|
||||
const [aiLogs, setAiLogs] = useState<ScanAiLogEntry[]>([]);
|
||||
@@ -659,7 +717,7 @@ function ScanTerminalScreen() {
|
||||
: "每城包含 EMOS 分布、模型集群和候选合约。",
|
||||
},
|
||||
]);
|
||||
setActiveView("ai");
|
||||
setActiveView("list");
|
||||
setAiLoading(true);
|
||||
setAiError(null);
|
||||
try {
|
||||
@@ -778,10 +836,10 @@ function ScanTerminalScreen() {
|
||||
}, [activeFilters, isPro]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPro && activeView !== "map") {
|
||||
if (!store.proAccess.loading && !isPro && activeView !== "map") {
|
||||
setActiveView("map");
|
||||
}
|
||||
}, [activeView, isPro]);
|
||||
}, [activeView, isPro, store.proAccess.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
setUserLocalTime(formatUserLocalTime());
|
||||
@@ -896,19 +954,6 @@ function ScanTerminalScreen() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (resolvedView === "ai") {
|
||||
return (
|
||||
<ScanAiAnalysisView
|
||||
response={terminalData}
|
||||
rows={timeSortedRows}
|
||||
logs={aiLogs}
|
||||
loading={aiLoading}
|
||||
error={aiError}
|
||||
locale={locale}
|
||||
onRunAi={() => void runAiReview()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<OpportunityTable
|
||||
@@ -1088,20 +1133,6 @@ function ScanTerminalScreen() {
|
||||
>
|
||||
{isEn ? "Opportunity List" : "机会列表"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={resolvedView === "ai" ? "active" : ""}
|
||||
title={!isPro ? (isEn ? "Pro scan required" : "扫描需 Pro") : undefined}
|
||||
onClick={() => {
|
||||
if (!isPro) {
|
||||
openScanPaywall();
|
||||
return;
|
||||
}
|
||||
setActiveView("ai");
|
||||
}}
|
||||
>
|
||||
{isEn ? "V4 Analysis" : "V4 分析"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={resolvedView === "calendar" ? "active" : ""}
|
||||
@@ -1144,7 +1175,18 @@ function ScanTerminalScreen() {
|
||||
<div className="scan-empty-copy">{staleReason}</div>
|
||||
</div>
|
||||
) : (
|
||||
renderMainView()
|
||||
<>
|
||||
{renderMainView()}
|
||||
{isPro ? (
|
||||
<ScanAiLogPanel
|
||||
response={terminalData}
|
||||
logs={aiLogs}
|
||||
locale={locale}
|
||||
loading={aiLoading}
|
||||
error={aiError}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -492,11 +492,14 @@ export interface ScanOpportunityRow {
|
||||
quote_age_ms?: number | null;
|
||||
edge?: number | null;
|
||||
edge_percent?: number | null;
|
||||
kelly_fraction?: number | null;
|
||||
quarter_kelly?: number | null;
|
||||
edge_score?: number | null;
|
||||
bias_score?: number | null;
|
||||
consensus_score?: number | null;
|
||||
distribution_bias?: DistributionBias | null;
|
||||
distribution_preview?: DistributionPreviewPoint[] | null;
|
||||
distribution_full?: DistributionPreviewPoint[] | null;
|
||||
distribution_bias_direction?: string | null;
|
||||
distribution_bias_score?: number | null;
|
||||
distribution_bias_available?: boolean;
|
||||
|
||||
@@ -3083,6 +3083,7 @@ class PolymarketReadOnlyLayer:
|
||||
)
|
||||
edge = model_probability - ask
|
||||
edge_percent = edge * 100.0
|
||||
kelly_fraction = edge / (1.0 - ask) if 0.0 < ask < 1.0 else None
|
||||
liquidity_reference = max(
|
||||
_safe_float(
|
||||
entry.get("yes_book_liquidity") if side == "yes" else entry.get("no_book_liquidity")
|
||||
@@ -3185,6 +3186,12 @@ class PolymarketReadOnlyLayer:
|
||||
"quote_age_ms": entry.get("quote_age_ms"),
|
||||
"edge": edge,
|
||||
"edge_percent": edge_percent,
|
||||
"kelly_fraction": kelly_fraction,
|
||||
"quarter_kelly": (
|
||||
max(0.0, kelly_fraction) / 4.0
|
||||
if kelly_fraction is not None
|
||||
else None
|
||||
),
|
||||
"edge_score": edge_score,
|
||||
"bias_score": bias_score,
|
||||
"consensus_score": consensus_score,
|
||||
|
||||
@@ -355,8 +355,11 @@ def _compact_ai_candidate(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"spread": row.get("spread"),
|
||||
"quote_age_ms": row.get("quote_age_ms"),
|
||||
"edge_percent": row.get("edge_percent"),
|
||||
"kelly_fraction": row.get("kelly_fraction"),
|
||||
"quarter_kelly": row.get("quarter_kelly"),
|
||||
"final_score": row.get("final_score"),
|
||||
"cluster_role": row.get("cluster_role"),
|
||||
"model_cluster_sources": _compact_ai_model_sources(row),
|
||||
"trend_alignment": row.get("trend_alignment"),
|
||||
"tradable": row.get("tradable"),
|
||||
"accepting_orders": row.get("accepting_orders"),
|
||||
@@ -483,6 +486,8 @@ def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"再决定哪些合约值得推荐、哪些必须排除、哪些只降级观察。"
|
||||
"重点规则:最高温市场一天只会落入一个桶;如果 DEB/模型集群/EMOS 主峰集中在更高温区,"
|
||||
"低温 YES 即使有表面 edge 也通常应 veto,优先考虑相邻尾部 NO;若价格过期、盘口太宽或窗口不支持,也要降级。"
|
||||
"任何基于 edge 或 Kelly/仓位的推荐,都必须先参考该城市全部 model_cluster.sources、DEB 和 EMOS 分布;"
|
||||
"不得只凭单一 EMOS 概率、单一模型或表面 edge 推荐。"
|
||||
"只能引用输入中的 row id。必须输出 JSON object。"
|
||||
)
|
||||
model_snapshot = dict(ai_input)
|
||||
@@ -495,6 +500,7 @@ def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"recommended_row_ids, vetoed_row_ids. recommendations items need row_id, rank, decision, "
|
||||
"confidence, reason_zh, reason_en, model_cluster_note. vetoed/downgraded items need row_id, "
|
||||
"reason_zh/reason_en. watchlist items are optional and need row_id plus reason. "
|
||||
"If a recommendation cites edge or Kelly, the reason must mention the full model cluster consensus or divergence. "
|
||||
"Keep every thesis and reason concise: one sentence only, no markdown, no repeated data tables."
|
||||
),
|
||||
"snapshot": model_snapshot,
|
||||
|
||||
Reference in New Issue
Block a user