feat: implement ScanTerminalDashboard with modular components and backend integration for Polymarket data collection

This commit is contained in:
2569718930@qq.com
2026-04-25 03:26:28 +08:00
parent b614778f13
commit 0e5a1f2652
8 changed files with 753 additions and 68 deletions
+2
View File
@@ -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>
+3
View File
@@ -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,
+6
View File
@@ -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,