feat: implement Polymarket read-only data service and add scan terminal dashboard components
This commit is contained in:
@@ -9251,35 +9251,25 @@
|
||||
border-bottom: 1px solid rgba(118, 146, 188, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-tabs) {
|
||||
.root :global(.scan-topbar-title) {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-tab) {
|
||||
position: relative;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #b4c8e2;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
.root :global(.scan-topbar-title strong) {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.04em;
|
||||
color: #f3f8ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-tab.active) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-tab.active::after) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -18px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: #3f8cff;
|
||||
box-shadow: 0 0 14px rgba(63, 140, 255, 0.4);
|
||||
.root :global(.scan-topbar-title span) {
|
||||
color: #8fa4c3;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-actions) {
|
||||
@@ -9329,8 +9319,6 @@
|
||||
.root :global(.scan-ghost-button),
|
||||
.root :global(.scan-cta-ghost),
|
||||
.root :global(.scan-theme-button),
|
||||
.root :global(.scan-sort-pill),
|
||||
.root :global(.scan-icon-pill),
|
||||
.root :global(.scan-detail-action-button),
|
||||
.root :global(.scan-detail-icon-button),
|
||||
.root :global(.scan-view-all-button) {
|
||||
@@ -9386,21 +9374,8 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.root :global(.scan-hero h1) {
|
||||
margin: 0;
|
||||
font-size: 44px;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.root :global(.scan-hero p) {
|
||||
margin: 10px 0 0;
|
||||
color: #8fa4c3;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-bar) {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -9425,6 +9400,10 @@
|
||||
color: #17d98b;
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-card.cyan .scan-kpi-label) {
|
||||
color: #37d6ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-card.purple .scan-kpi-label) {
|
||||
color: #a35cff;
|
||||
}
|
||||
@@ -9433,6 +9412,14 @@
|
||||
color: #49a3ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-card.red .scan-kpi-label) {
|
||||
color: #ff647c;
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-card.amber .scan-kpi-label) {
|
||||
color: #ffb84f;
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-card.orange .scan-kpi-label) {
|
||||
color: #ffb020;
|
||||
}
|
||||
@@ -9487,20 +9474,37 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.root :global(.scan-list-controls) {
|
||||
.root :global(.scan-list-status) {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-sort-pill) {
|
||||
min-width: 188px;
|
||||
padding: 12px 14px;
|
||||
.root :global(.scan-status-chip) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(98, 126, 167, 0.22);
|
||||
background: rgba(10, 22, 38, 0.92);
|
||||
color: #c4d4e9;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.root :global(.scan-icon-pill) {
|
||||
width: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
.root :global(.scan-status-chip.live) {
|
||||
border-color: rgba(55, 214, 255, 0.28);
|
||||
background: rgba(55, 214, 255, 0.1);
|
||||
color: #71e6ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-status-chip.stale) {
|
||||
border-color: rgba(255, 184, 79, 0.24);
|
||||
background: rgba(255, 184, 79, 0.1);
|
||||
color: #ffc765;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-shell) {
|
||||
@@ -9518,6 +9522,27 @@
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-banner) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(95, 124, 168, 0.12);
|
||||
background: linear-gradient(180deg, rgba(255, 184, 79, 0.08), rgba(255, 184, 79, 0.03));
|
||||
}
|
||||
|
||||
.root :global(.scan-table-banner strong) {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #ffd27f;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-banner span) {
|
||||
font-size: 13px;
|
||||
color: #c7d4e7;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-header) {
|
||||
display: grid;
|
||||
grid-template-columns: 46px minmax(180px, 1.08fr) minmax(132px, 0.72fr) minmax(220px, 1.08fr) minmax(150px, 0.82fr) 92px 82px;
|
||||
|
||||
@@ -153,8 +153,8 @@ function ProbabilityPreview({
|
||||
});
|
||||
}
|
||||
|
||||
const modelLabel = locale === "en-US" ? "M" : "模";
|
||||
const marketLabel = locale === "en-US" ? "P" : "市";
|
||||
const modelLabel = "EMOS";
|
||||
const marketLabel = locale === "en-US" ? "Market" : "市场";
|
||||
|
||||
return (
|
||||
<div className="scan-distribution-preview">
|
||||
@@ -202,28 +202,53 @@ function ScoreRing({ score }: { score?: number | null }) {
|
||||
|
||||
export const OpportunityTable = React.memo(function OpportunityTable({
|
||||
rows,
|
||||
status,
|
||||
stale,
|
||||
staleReason,
|
||||
loading,
|
||||
selectedRowId,
|
||||
onSelectRow,
|
||||
}: {
|
||||
rows: ScanOpportunityRow[];
|
||||
status?: string | null;
|
||||
stale?: boolean;
|
||||
staleReason?: string | null;
|
||||
loading?: boolean;
|
||||
selectedRowId?: string | null;
|
||||
onSelectRow?: (row: ScanOpportunityRow) => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
const hasRows = rows.length > 0;
|
||||
|
||||
if (!rows.length) {
|
||||
if (!hasRows) {
|
||||
const title =
|
||||
loading
|
||||
? isEn
|
||||
? "Scanning markets"
|
||||
: "正在扫描市场"
|
||||
: status === "failed"
|
||||
? isEn
|
||||
? "Scan failed"
|
||||
: "扫描失败"
|
||||
: isEn
|
||||
? "No tradable market right now"
|
||||
: "当前暂无可交易市场";
|
||||
const copy =
|
||||
loading
|
||||
? isEn
|
||||
? "Waiting for the latest market snapshot. Existing data will stay on screen when available."
|
||||
: "正在等待最新市场快照;如果有旧数据,会继续保留在页面上。"
|
||||
: status === "failed"
|
||||
? staleReason || (isEn ? "No valid market snapshot is available." : "当前没有可用的市场快照。")
|
||||
: isEn
|
||||
? "The current snapshot does not contain a tradable main signal."
|
||||
: "当前快照里还没有可交易的主信号。";
|
||||
return (
|
||||
<div className="scan-table-shell empty">
|
||||
<div className="scan-empty-state">
|
||||
<div className="scan-empty-title">
|
||||
{isEn ? "No main signal right now" : "当前无主信号"}
|
||||
</div>
|
||||
<div className="scan-empty-copy">
|
||||
{isEn
|
||||
? "No row passed the price, spread, liquidity, and edge thresholds."
|
||||
: "当前没有机会同时满足价格、点差、流动性和 edge 过滤。"}
|
||||
</div>
|
||||
<div className="scan-empty-title">{title}</div>
|
||||
<div className="scan-empty-copy">{copy}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -231,12 +256,18 @@ export const OpportunityTable = React.memo(function OpportunityTable({
|
||||
|
||||
return (
|
||||
<div className="scan-table-shell">
|
||||
{stale ? (
|
||||
<div className="scan-table-banner">
|
||||
<strong>{isEn ? "Showing delayed snapshot" : "当前显示延迟快照"}</strong>
|
||||
<span>{staleReason || (isEn ? "Latest refresh failed, fallback to the last successful scan." : "最新刷新失败,已回退到上次成功扫描结果。")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="scan-table-header">
|
||||
<span />
|
||||
<span>{isEn ? "City / Market" : "城市 / 市场"}</span>
|
||||
<span>{isEn ? "Local Time / Phase" : "当前时间 / 阶段"}</span>
|
||||
<span>{isEn ? "EMOS vs Market" : "EMOS 分布 vs 市场分布"}</span>
|
||||
<span>{isEn ? "Best Opportunity" : "最佳机会"}</span>
|
||||
<span>{isEn ? "EMOS / Market" : "EMOS / 市场"}</span>
|
||||
<span>{isEn ? "Quote / Model" : "买价 / 模型"}</span>
|
||||
<span>{isEn ? "Edge" : "边际优势"}</span>
|
||||
<span>{isEn ? "Score" : "综合得分"}</span>
|
||||
</div>
|
||||
@@ -292,8 +323,8 @@ export const OpportunityTable = React.memo(function OpportunityTable({
|
||||
{formatAction(row, locale, tempSymbol)}
|
||||
</div>
|
||||
<div className="scan-trade-sub">
|
||||
{formatPercent(row.ask != null ? row.ask * 100 : null)} →{" "}
|
||||
{formatPercent(row.model_probability != null ? row.model_probability * 100 : null)}
|
||||
{isEn ? "Buy" : "买价"} {row.ask != null ? `${Math.round(row.ask * 100)}¢` : "--"} ·{" "}
|
||||
EMOS {formatPercent(row.model_probability != null ? row.model_probability * 100 : null)}
|
||||
</div>
|
||||
<div className="scan-trade-note">
|
||||
{normalizeTemperatureLabel(row.target_label, tempSymbol) ||
|
||||
|
||||
@@ -2,56 +2,99 @@
|
||||
|
||||
import React from "react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import type { ScanTerminalResponse } from "@/lib/dashboard-types";
|
||||
import type { ScanOpportunityRow, ScanTerminalResponse } from "@/lib/dashboard-types";
|
||||
|
||||
type KPIData = ScanTerminalResponse["summary"];
|
||||
|
||||
function formatVolume(value: number): string {
|
||||
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `$${(value / 1_000).toFixed(0)}K`;
|
||||
return `$${value.toFixed(0)}`;
|
||||
function formatPercent(value?: number | null, signed = false): string {
|
||||
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||
const numeric = Number(value);
|
||||
return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function ScanKPIBar({ data }: { data: KPIData }) {
|
||||
function countByRisk(rows: ScanOpportunityRow[]) {
|
||||
return rows.reduce(
|
||||
(acc, row) => {
|
||||
const risk = String(row.risk_level || "").toLowerCase();
|
||||
if (risk.includes("high")) acc.high += 1;
|
||||
else if (risk.includes("medium")) acc.medium += 1;
|
||||
else acc.low += 1;
|
||||
return acc;
|
||||
},
|
||||
{ high: 0, medium: 0, low: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
export function ScanKPIBar({
|
||||
response,
|
||||
rows,
|
||||
totalCities,
|
||||
loading,
|
||||
}: {
|
||||
response: ScanTerminalResponse | null;
|
||||
rows: ScanOpportunityRow[];
|
||||
totalCities: number;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
const summary = response?.summary;
|
||||
const riskCounts = countByRisk(rows);
|
||||
const tradableRows = rows.filter((row) => row.tradable && !row.closed);
|
||||
const liveRows = rows.filter((row) => row.active || row.accepting_orders);
|
||||
const bestRow = rows[0] || null;
|
||||
const statusLabel =
|
||||
loading && !response
|
||||
? isEn
|
||||
? "Scanning"
|
||||
: "扫描中"
|
||||
: response?.status === "stale"
|
||||
? isEn
|
||||
? "Stale"
|
||||
: "旧数据"
|
||||
: response?.status === "failed"
|
||||
? isEn
|
||||
? "Failed"
|
||||
: "失败"
|
||||
: isEn
|
||||
? "Live"
|
||||
: "最新";
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: isEn ? "Recommended" : "推荐机会",
|
||||
value: String(data.recommended_count),
|
||||
note: `${isEn ? "Visible" : "当前展示"} ${data.visible_count}`,
|
||||
tone: "green",
|
||||
label: isEn ? "Scan Status" : "扫描状态",
|
||||
value: `${rows.length}/${totalCities || 0}`,
|
||||
note:
|
||||
loading && response
|
||||
? isEn
|
||||
? "Refreshing latest snapshot"
|
||||
: "正在刷新最新快照"
|
||||
: response?.status === "stale"
|
||||
? response.stale_reason || (isEn ? "Using last good snapshot" : "正在使用上次成功快照")
|
||||
: response?.status === "failed"
|
||||
? response.stale_reason || (isEn ? "No valid snapshot" : "当前没有可用快照")
|
||||
: `${isEn ? "Snapshot" : "快照"} · ${statusLabel}`,
|
||||
tone: response?.status === "failed" ? "red" : response?.status === "stale" ? "amber" : "cyan",
|
||||
},
|
||||
{
|
||||
label: isEn ? "Avg Edge" : "平均边际优势",
|
||||
value:
|
||||
data.avg_edge_percent != null
|
||||
? `+${data.avg_edge_percent.toFixed(1)}%`
|
||||
: "--",
|
||||
note: `${isEn ? "Candidates" : "候选总数"} ${data.candidate_total}`,
|
||||
tone: "purple",
|
||||
},
|
||||
{
|
||||
label: isEn ? "Avg Confidence" : "平均主信号置信度",
|
||||
value:
|
||||
data.avg_primary_confidence != null
|
||||
? `+${data.avg_primary_confidence.toFixed(1)}%`
|
||||
: "--",
|
||||
note: isEn ? "Main signal score" : "主信号评分",
|
||||
label: isEn ? "Book Status" : "盘口状态",
|
||||
value: `${liveRows.length}`,
|
||||
note: `${isEn ? "Tradable" : "可交易"} ${tradableRows.length} · ${isEn ? "No edge" : "无机会"} ${Math.max(0, rows.length - tradableRows.length)}`,
|
||||
tone: "blue",
|
||||
},
|
||||
{
|
||||
label: isEn ? "Tradable Markets" : "可交易市场",
|
||||
value: String(data.tradable_market_count),
|
||||
note: `${isEn ? "Filtered" : "过滤后"} / ${data.candidate_total || 0}`,
|
||||
tone: "orange",
|
||||
label: isEn ? "Opportunity Quality" : "机会质量",
|
||||
value: formatPercent(summary?.avg_edge_percent, true),
|
||||
note: bestRow
|
||||
? `${isEn ? "Best" : "最佳"} ${bestRow.city_display_name || bestRow.display_name || bestRow.city} · ${formatPercent(bestRow.edge_percent, true)}`
|
||||
: isEn
|
||||
? "No active market now"
|
||||
: "当前没有活跃市场",
|
||||
tone: "green",
|
||||
},
|
||||
{
|
||||
label: isEn ? "Total Volume" : "总成交量",
|
||||
value: formatVolume(data.total_volume),
|
||||
note: isEn ? "Past 24 hours" : "过去 24 小时",
|
||||
tone: "neutral",
|
||||
label: isEn ? "Risk Layers" : "风险层",
|
||||
value: `${riskCounts.high} / ${riskCounts.medium} / ${riskCounts.low}`,
|
||||
note: `${isEn ? "High / Med / Low" : "高 / 中 / 低"}`,
|
||||
tone: "purple",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Bell,
|
||||
Menu,
|
||||
RefreshCw,
|
||||
Moon,
|
||||
Sun,
|
||||
@@ -64,26 +62,8 @@ const DEFAULT_FILTERS: FilterState = {
|
||||
limit: 28,
|
||||
};
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ zh: "扫描台", en: "Terminal" },
|
||||
{ zh: "市场", en: "Markets" },
|
||||
{ zh: "分析", en: "Analysis" },
|
||||
{ zh: "组合", en: "Portfolio" },
|
||||
{ zh: "监控", en: "Monitor" },
|
||||
{ zh: "设置", en: "Settings" },
|
||||
];
|
||||
|
||||
type TopSection = "terminal" | "markets" | "analysis" | "portfolio" | "monitor" | "settings";
|
||||
type ContentView = "list" | "map" | "calendar";
|
||||
type ThemeMode = "dark" | "light";
|
||||
const TOP_SECTION_ORDER: TopSection[] = [
|
||||
"terminal",
|
||||
"markets",
|
||||
"analysis",
|
||||
"portfolio",
|
||||
"monitor",
|
||||
"settings",
|
||||
];
|
||||
|
||||
function formatPercent(value?: number | null, signed = false) {
|
||||
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||
@@ -379,9 +359,6 @@ function DetailPanel({
|
||||
{loading ? ` · ${isEn ? "loading" : "载入中"}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="scan-detail-action-button">
|
||||
{isEn ? "Add Watch" : "添加自选"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="scan-detail-primary-actions">
|
||||
@@ -443,18 +420,9 @@ function DetailPanel({
|
||||
</strong>
|
||||
</div>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Window Left" : "剩余有效时间"}</span>
|
||||
<span>{isEn ? "Time To Peak" : "距离预测峰值"}</span>
|
||||
<strong>{formatRemainingWindow(displayRow.remaining_window_minutes, locale)}</strong>
|
||||
</div>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Bias" : "分布偏移"}</span>
|
||||
<strong>
|
||||
{displayRow.distribution_bias_direction || "--"} ·{" "}
|
||||
{displayRow.distribution_bias_score != null
|
||||
? displayRow.distribution_bias_score.toFixed(0)
|
||||
: "--"}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Airport" : "机场锚点"}</span>
|
||||
<strong>{localizedAirport || "--"}</strong>
|
||||
@@ -521,38 +489,35 @@ function DetailPanel({
|
||||
</section>
|
||||
|
||||
<section className="scan-detail-section">
|
||||
<div className="scan-trade-cards">
|
||||
<div className="scan-trade-card buy">
|
||||
<div className="scan-trade-card-title">
|
||||
{isEn ? "Buy Yes" : "买入 Yes"}{" "}
|
||||
{normalizeTemperatureLabel(displayRow.target_label, tempSymbol) || ""}
|
||||
</div>
|
||||
<p>
|
||||
{formatPrice(getDetailSideAsk(yesRow, marketScan, "yes"))} →{" "}
|
||||
{formatProbability(yesRow?.model_probability)}
|
||||
</p>
|
||||
<p className="positive">{formatPercent(yesRow?.edge_percent, true)} {isEn ? "edge" : "边际优势"}</p>
|
||||
<p>
|
||||
{isEn ? "Bid / Ask" : "买卖价"} {formatPrice(getDetailSideBid(yesRow, marketScan, "yes"))} /{" "}
|
||||
{formatPrice(getDetailSideAsk(yesRow, marketScan, "yes"))}
|
||||
</p>
|
||||
<div className="scan-detail-section-title">
|
||||
{isEn ? "Main Signal" : "主信号概况"}
|
||||
</div>
|
||||
<div className="scan-kv-list compact">
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Best Side" : "主方向"}</span>
|
||||
<strong>{displayRow.side === "no" ? "NO" : "YES"}</strong>
|
||||
</div>
|
||||
<div className="scan-trade-card sell">
|
||||
<div className="scan-trade-card-title">
|
||||
{isEn ? "Buy No" : "买入 No"}{" "}
|
||||
{normalizeTemperatureLabel(displayRow.target_label, tempSymbol) || ""}
|
||||
</div>
|
||||
<p>
|
||||
{formatPrice(getDetailSideAsk(noRow, marketScan, "no"))} →{" "}
|
||||
{formatProbability(noRow?.model_probability)}
|
||||
</p>
|
||||
<p className={Number(noRow?.edge_percent || 0) >= 0 ? "positive" : "negative"}>
|
||||
{formatPercent(noRow?.edge_percent, true)} {isEn ? "edge" : "边际优势"}
|
||||
</p>
|
||||
<p>
|
||||
{isEn ? "Bid / Ask" : "买卖价"} {formatPrice(getDetailSideBid(noRow, marketScan, "no"))} /{" "}
|
||||
{formatPrice(getDetailSideAsk(noRow, marketScan, "no"))}
|
||||
</p>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Best Buy" : "最优买价"}</span>
|
||||
<strong>
|
||||
{displayRow.side === "no"
|
||||
? formatPrice(getDetailSideAsk(noRow, marketScan, "no"))
|
||||
: formatPrice(getDetailSideAsk(yesRow, marketScan, "yes"))}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "EMOS" : "EMOS 概率"}</span>
|
||||
<strong>
|
||||
{displayRow.side === "no"
|
||||
? formatProbability(noRow?.model_probability)
|
||||
: formatProbability(yesRow?.model_probability)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Edge" : "边际优势"}</span>
|
||||
<strong className={Number(displayRow.edge_percent || 0) >= 0 ? "positive" : "negative"}>
|
||||
{formatPercent(displayRow.edge_percent, true)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -630,6 +595,11 @@ function CalendarView({
|
||||
className={`scan-calendar-card ${selectedRowId === row.id ? "selected" : ""}`}
|
||||
onClick={() => onSelectRow(row)}
|
||||
>
|
||||
{(() => {
|
||||
const tempSymbol = row.temp_symbol || "°C";
|
||||
const phaseMeta = getWindowPhaseMeta(row, locale);
|
||||
return (
|
||||
<>
|
||||
<div className="scan-calendar-city">
|
||||
{getLocalizedCityName(
|
||||
row.city,
|
||||
@@ -637,13 +607,19 @@ function CalendarView({
|
||||
locale,
|
||||
)}
|
||||
</div>
|
||||
<div className={`scan-calendar-action ${row.side === "no" ? "sell" : "buy"}`}>
|
||||
{row.action || row.target_label || "--"}
|
||||
<div className="scan-calendar-action">
|
||||
{locale === "en-US" ? "DEB high" : "DEB 预测高点"} ·{" "}
|
||||
{row.deb_prediction != null
|
||||
? formatTemperatureValue(row.deb_prediction, tempSymbol)
|
||||
: "--"}
|
||||
</div>
|
||||
<div className="scan-calendar-meta">
|
||||
<span>{row.local_time || "--"}</span>
|
||||
<span>{formatPercent(row.edge_percent, true)}</span>
|
||||
<span>{phaseMeta.label}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -670,97 +646,6 @@ function OverviewMapView({ locale }: { locale: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PortfolioView({
|
||||
rows,
|
||||
locale,
|
||||
}: {
|
||||
rows: ScanOpportunityRow[];
|
||||
locale: string;
|
||||
}) {
|
||||
const yesCount = rows.filter((row) => row.side === "yes").length;
|
||||
const noCount = rows.filter((row) => row.side === "no").length;
|
||||
const avgScore =
|
||||
rows.length > 0
|
||||
? rows.reduce((sum, row) => sum + Number(row.final_score || 0), 0) / rows.length
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="scan-summary-grid">
|
||||
<div className="scan-summary-card">
|
||||
<div className="scan-summary-label">{locale === "en-US" ? "YES Ideas" : "YES 机会"}</div>
|
||||
<div className="scan-summary-value">{yesCount}</div>
|
||||
</div>
|
||||
<div className="scan-summary-card">
|
||||
<div className="scan-summary-label">{locale === "en-US" ? "NO Ideas" : "NO 机会"}</div>
|
||||
<div className="scan-summary-value">{noCount}</div>
|
||||
</div>
|
||||
<div className="scan-summary-card wide">
|
||||
<div className="scan-summary-label">{locale === "en-US" ? "Avg Score" : "平均评分"}</div>
|
||||
<div className="scan-summary-value">
|
||||
{avgScore != null ? `${avgScore.toFixed(0)}/100` : "--"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorView({
|
||||
rows,
|
||||
locale,
|
||||
}: {
|
||||
rows: ScanOpportunityRow[];
|
||||
locale: string;
|
||||
}) {
|
||||
const groups = useMemo(() => {
|
||||
const result = new Map<string, number>();
|
||||
rows.forEach((row) => {
|
||||
const label = getWindowPhaseMeta(row, locale).label;
|
||||
result.set(label, (result.get(label) || 0) + 1);
|
||||
});
|
||||
return Array.from(result.entries());
|
||||
}, [rows, locale]);
|
||||
|
||||
return (
|
||||
<div className="scan-summary-grid monitor">
|
||||
{groups.map(([label, count]) => (
|
||||
<div key={label} className="scan-summary-card">
|
||||
<div className="scan-summary-label">{label}</div>
|
||||
<div className="scan-summary-value">{count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsView({ locale }: { locale: string }) {
|
||||
return (
|
||||
<div className="scan-settings-view">
|
||||
<div className="scan-settings-card">
|
||||
<div className="scan-summary-label">{locale === "en-US" ? "Market Discovery" : "市场发现"}</div>
|
||||
<div className="scan-settings-copy">
|
||||
{locale === "en-US" ? "Gamma REST refreshes every 60s." : "Gamma REST 每 60 秒刷新一次。"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-settings-card">
|
||||
<div className="scan-summary-label">{locale === "en-US" ? "Price Layer" : "价格层"}</div>
|
||||
<div className="scan-settings-copy">
|
||||
{locale === "en-US"
|
||||
? "CLOB REST prices and books refresh every 30s."
|
||||
: "CLOB REST 价格和盘口每 30 秒刷新一次。"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-settings-card">
|
||||
<div className="scan-summary-label">{locale === "en-US" ? "Server Profile" : "服务器配置"}</div>
|
||||
<div className="scan-settings-copy">
|
||||
{locale === "en-US"
|
||||
? "2GB profile: one polling loop, cached REST, no websocket fan-out."
|
||||
: "2G 配置:单轮询、REST 缓存优先、不做 websocket 扇出。"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScanTerminalScreen() {
|
||||
const store = useDashboardStore();
|
||||
const { locale, toggleLocale } = useI18n();
|
||||
@@ -776,7 +661,6 @@ function ScanTerminalScreen() {
|
||||
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
||||
const [detailByRowId, setDetailByRowId] = useState<Record<string, MarketScan | null>>({});
|
||||
const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null);
|
||||
const [activeSection, setActiveSection] = useState<TopSection>("terminal");
|
||||
const [activeView, setActiveView] = useState<ContentView>("list");
|
||||
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
||||
const [userLocalTime, setUserLocalTime] = useState("--");
|
||||
@@ -806,12 +690,12 @@ function ScanTerminalScreen() {
|
||||
|
||||
const fetchTerminal = async (filters: FilterState, force = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await dashboardClient.getScanTerminal(filters, { force });
|
||||
startTransition(() => {
|
||||
setTerminalData(response);
|
||||
setActiveFilters(filters);
|
||||
setError(response.status === "failed" ? response.stale_reason || null : null);
|
||||
setSelectedRowId((current) => {
|
||||
if (current && response.rows.some((row) => row.id === current)) {
|
||||
return current;
|
||||
@@ -827,7 +711,7 @@ function ScanTerminalScreen() {
|
||||
};
|
||||
|
||||
const fetchDetail = async (row: ScanOpportunityRow) => {
|
||||
if (!row.market_slug || !row.selected_date) return;
|
||||
if (!row.market_slug || !row.selected_date || row.closed) return;
|
||||
if (detailByRowId[row.id] !== undefined) return;
|
||||
setDetailLoadingId(row.id);
|
||||
try {
|
||||
@@ -880,15 +764,13 @@ function ScanTerminalScreen() {
|
||||
window.localStorage.setItem("polyweather_scan_theme", themeMode);
|
||||
}, [themeMode]);
|
||||
|
||||
const resolvedView: ContentView = activeView;
|
||||
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
|
||||
const resolvedView: ContentView =
|
||||
activeSection === "markets"
|
||||
? "map"
|
||||
: activeSection === "analysis"
|
||||
? "calendar"
|
||||
: activeView;
|
||||
const activeDetailRow = resolvedView === "map" && mapFocusedCity ? mapFocusedRow : selectedRow;
|
||||
const selectedDetail = activeDetailRow ? detailByRowId[activeDetailRow.id] : null;
|
||||
const scanStatus = terminalData?.status || (loading ? "loading" : error ? "failed" : "ready");
|
||||
const staleReason =
|
||||
terminalData?.stale_reason || error || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeDetailRow) return;
|
||||
@@ -914,15 +796,6 @@ function ScanTerminalScreen() {
|
||||
}, []);
|
||||
|
||||
const renderMainView = () => {
|
||||
if (activeSection === "portfolio") {
|
||||
return <PortfolioView rows={timeSortedRows} locale={locale} />;
|
||||
}
|
||||
if (activeSection === "monitor") {
|
||||
return <MonitorView rows={timeSortedRows} locale={locale} />;
|
||||
}
|
||||
if (activeSection === "settings") {
|
||||
return <SettingsView locale={locale} />;
|
||||
}
|
||||
if (resolvedView === "map") {
|
||||
return (
|
||||
<div className="scan-map-view">
|
||||
@@ -951,6 +824,10 @@ function ScanTerminalScreen() {
|
||||
<>
|
||||
<OpportunityTable
|
||||
rows={timeSortedRows}
|
||||
status={scanStatus}
|
||||
stale={Boolean(terminalData?.stale)}
|
||||
staleReason={staleReason}
|
||||
loading={loading}
|
||||
selectedRowId={selectedRowId}
|
||||
onSelectRow={handleSelectRow}
|
||||
/>
|
||||
@@ -973,19 +850,21 @@ function ScanTerminalScreen() {
|
||||
|
||||
<main className="scan-data-grid">
|
||||
<div className="scan-topbar">
|
||||
<div className="scan-topbar-tabs">
|
||||
{NAV_ITEMS.map((item, index) => (
|
||||
<button
|
||||
key={item.zh}
|
||||
type="button"
|
||||
className={`scan-topbar-tab ${
|
||||
TOP_SECTION_ORDER[index] === activeSection ? "active" : ""
|
||||
}`}
|
||||
onClick={() => setActiveSection(TOP_SECTION_ORDER[index])}
|
||||
>
|
||||
{isEn ? item.en : item.zh}
|
||||
</button>
|
||||
))}
|
||||
<div className="scan-topbar-title">
|
||||
<strong>{isEn ? "Market Scan Terminal" : "市场扫描台"}</strong>
|
||||
<span>
|
||||
{loading
|
||||
? isEn
|
||||
? "Refreshing current market snapshot"
|
||||
: "正在刷新当前市场快照"
|
||||
: terminalData?.stale
|
||||
? isEn
|
||||
? "Showing the last successful snapshot"
|
||||
: "当前显示上次成功快照"
|
||||
: isEn
|
||||
? "Read-only market scan with peak-first main signal"
|
||||
: "只读市场扫描,主信号按 EMOS 主峰优先"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-topbar-actions">
|
||||
<button
|
||||
@@ -1012,11 +891,7 @@ function ScanTerminalScreen() {
|
||||
</button>
|
||||
<button type="button" className="scan-ghost-button" onClick={() => void fetchTerminal(activeFilters, true)}>
|
||||
<RefreshCw size={14} className={loading ? "spin" : undefined} />
|
||||
{isEn ? "Refresh" : "筛选"}
|
||||
</button>
|
||||
<button type="button" className="scan-cta-ghost">
|
||||
<Bell size={14} />
|
||||
{isEn ? "Custom Alerts" : "自定义提醒"}
|
||||
{isEn ? "Refresh" : "刷新"}
|
||||
</button>
|
||||
<Link
|
||||
href={accountHref}
|
||||
@@ -1029,28 +904,11 @@ function ScanTerminalScreen() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="scan-hero">
|
||||
<h1>{isEn ? "Tradable Opportunities" : "可交易机会"}</h1>
|
||||
<p>
|
||||
{isEn
|
||||
? "Use EMOS distribution, live order book, and timing windows to isolate the one actionable signal."
|
||||
: "基于当前时间、实况数据和模型预测,筛选出最具交易价值的市场。"}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<ScanKPIBar
|
||||
data={
|
||||
terminalData?.summary || {
|
||||
recommended_count: 0,
|
||||
visible_count: 0,
|
||||
candidate_total: 0,
|
||||
avg_edge_percent: null,
|
||||
avg_primary_confidence: null,
|
||||
tradable_market_count: 0,
|
||||
total_volume: 0,
|
||||
resolved_market_type: "maxtemp",
|
||||
}
|
||||
}
|
||||
response={terminalData}
|
||||
rows={timeSortedRows}
|
||||
totalCities={store.cities.length}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<section className="scan-list-section">
|
||||
@@ -1058,9 +916,8 @@ function ScanTerminalScreen() {
|
||||
<div className="scan-list-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={activeSection === "terminal" && resolvedView === "list" ? "active" : ""}
|
||||
className={resolvedView === "list" ? "active" : ""}
|
||||
onClick={() => {
|
||||
setActiveSection("terminal");
|
||||
setActiveView("list");
|
||||
}}
|
||||
>
|
||||
@@ -1070,7 +927,6 @@ function ScanTerminalScreen() {
|
||||
type="button"
|
||||
className={resolvedView === "map" ? "active" : ""}
|
||||
onClick={() => {
|
||||
setActiveSection("terminal");
|
||||
setActiveView("map");
|
||||
}}
|
||||
>
|
||||
@@ -1080,39 +936,32 @@ function ScanTerminalScreen() {
|
||||
type="button"
|
||||
className={resolvedView === "calendar" ? "active" : ""}
|
||||
onClick={() => {
|
||||
setActiveSection("terminal");
|
||||
setActiveView("calendar");
|
||||
}}
|
||||
>
|
||||
{isEn ? "Calendar View" : "日历视图"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="scan-list-controls">
|
||||
<div className="scan-sort-pill">
|
||||
{isEn
|
||||
? resolvedView === "calendar"
|
||||
? "Sort: Date"
|
||||
: resolvedView === "map"
|
||||
? "Sort: City Map"
|
||||
: "Sort: Your Time"
|
||||
: resolvedView === "calendar"
|
||||
? "排序:日期"
|
||||
: resolvedView === "map"
|
||||
? "排序:地图"
|
||||
: "排序:用户时区时间"}
|
||||
</div>
|
||||
<button type="button" className="scan-icon-pill" aria-label="menu">
|
||||
<Menu size={16} />
|
||||
</button>
|
||||
<div className="scan-list-status">
|
||||
{terminalData?.stale ? (
|
||||
<span className="scan-status-chip stale">
|
||||
{isEn ? "Delayed snapshot" : "延迟快照"}
|
||||
</span>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<span className="scan-status-chip live">
|
||||
{isEn ? "Refreshing" : "刷新中"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
{scanStatus === "failed" && !terminalData ? (
|
||||
<div className="scan-empty-state">
|
||||
<div className="scan-empty-title">
|
||||
{isEn ? "Scan failed" : "扫描失败"}
|
||||
</div>
|
||||
<div className="scan-empty-copy">{error}</div>
|
||||
<div className="scan-empty-copy">{staleReason}</div>
|
||||
</div>
|
||||
) : (
|
||||
renderMainView()
|
||||
|
||||
@@ -497,6 +497,11 @@ export interface ScanOpportunityRow {
|
||||
distribution_bias_direction?: string | null;
|
||||
distribution_bias_score?: number | null;
|
||||
distribution_bias_available?: boolean;
|
||||
peak_probability?: number | null;
|
||||
peak_value?: number | null;
|
||||
peak_distance?: number | null;
|
||||
peak_alignment_score?: number | null;
|
||||
is_peak_candidate?: boolean;
|
||||
window_phase?: string | null;
|
||||
window_score?: number | null;
|
||||
remaining_window_minutes?: number | null;
|
||||
@@ -525,6 +530,12 @@ export interface PrimarySignal extends ScanOpportunityRow {}
|
||||
|
||||
export interface ScanTerminalResponse {
|
||||
generated_at: string;
|
||||
snapshot_id?: string | null;
|
||||
status?: "ready" | "stale" | "failed" | string;
|
||||
stale?: boolean;
|
||||
stale_reason?: string | null;
|
||||
last_success_at?: string | null;
|
||||
last_failed_at?: string | null;
|
||||
filters: ScanTerminalFilters;
|
||||
summary: {
|
||||
recommended_count: number;
|
||||
|
||||
@@ -2810,12 +2810,60 @@ class PolymarketReadOnlyLayer:
|
||||
)
|
||||
distribution_preview[highlighted_index]["highlighted"] = True
|
||||
|
||||
peak_probability = None
|
||||
peak_value = None
|
||||
if distribution_preview:
|
||||
highlighted_preview = next(
|
||||
(item for item in distribution_preview if item.get("highlighted")),
|
||||
None,
|
||||
)
|
||||
if isinstance(highlighted_preview, dict):
|
||||
peak_probability = _safe_float(highlighted_preview.get("model_probability"))
|
||||
peak_value = _safe_float(highlighted_preview.get("value"))
|
||||
|
||||
ordered_entry_indices = sorted(
|
||||
range(len(market_entries)),
|
||||
key=lambda index: (
|
||||
_safe_float(market_entries[index].get("bucket_temp"))
|
||||
if _safe_float(market_entries[index].get("bucket_temp")) is not None
|
||||
else float("inf"),
|
||||
str(market_entries[index].get("target_label") or ""),
|
||||
),
|
||||
)
|
||||
entry_order_map = {
|
||||
ordered_entry_indices[position]: position
|
||||
for position in range(len(ordered_entry_indices))
|
||||
}
|
||||
peak_entry_order = None
|
||||
if peak_value is not None and ordered_entry_indices:
|
||||
peak_entry_order = min(
|
||||
range(len(ordered_entry_indices)),
|
||||
key=lambda position: abs(
|
||||
(
|
||||
_safe_float(
|
||||
market_entries[ordered_entry_indices[position]].get("bucket_temp")
|
||||
)
|
||||
if _safe_float(
|
||||
market_entries[ordered_entry_indices[position]].get("bucket_temp")
|
||||
)
|
||||
is not None
|
||||
else peak_value
|
||||
)
|
||||
- peak_value
|
||||
),
|
||||
)
|
||||
|
||||
current_reference_raw = _safe_float(
|
||||
(scan_context or {}).get("current_max_so_far")
|
||||
or (scan_context or {}).get("current_temp")
|
||||
)
|
||||
|
||||
def _row_from_entry(entry: Dict[str, Any], side: str) -> Optional[Dict[str, Any]]:
|
||||
def _row_from_entry(
|
||||
entry: Dict[str, Any],
|
||||
side: str,
|
||||
*,
|
||||
entry_index: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
model_event_probability = _clamp_probability(_safe_float(entry.get("model_event_probability")))
|
||||
market_event_probability = _clamp_probability(_safe_float(entry.get("market_event_probability")))
|
||||
ask = _clamp_probability(_safe_float(entry.get("yes_ask") if side == "yes" else entry.get("no_ask")))
|
||||
@@ -2850,6 +2898,21 @@ class PolymarketReadOnlyLayer:
|
||||
if target_threshold is not None and current_reference is not None
|
||||
else None
|
||||
)
|
||||
entry_order = entry_order_map.get(entry_index)
|
||||
peak_distance = None
|
||||
is_peak_candidate = False
|
||||
if entry_order is not None and peak_entry_order is not None:
|
||||
peak_distance = abs(entry_order - peak_entry_order)
|
||||
is_peak_candidate = peak_distance <= 1
|
||||
peak_alignment_score = 0.0
|
||||
if peak_distance is None:
|
||||
peak_alignment_score = 0.35
|
||||
elif peak_distance == 0:
|
||||
peak_alignment_score = 1.0
|
||||
elif peak_distance == 1:
|
||||
peak_alignment_score = 0.8
|
||||
else:
|
||||
peak_alignment_score = max(0.0, 0.55 - 0.15 * float(peak_distance - 2))
|
||||
temperature_direction = self._resolve_temperature_direction(
|
||||
side=side,
|
||||
market_direction=str(entry.get("market_direction") or "exact"),
|
||||
@@ -2899,6 +2962,7 @@ class PolymarketReadOnlyLayer:
|
||||
+ 0.20 * float(window_meta.get("score") or 0.0)
|
||||
+ 0.10 * liquidity_score
|
||||
+ 0.10 * price_usefulness_score
|
||||
+ 0.08 * peak_alignment_score
|
||||
) - spread_penalty
|
||||
market_slug = str(market.get("slug") or "").strip()
|
||||
target_label = str(entry.get("target_label") or "").strip()
|
||||
@@ -2974,6 +3038,11 @@ class PolymarketReadOnlyLayer:
|
||||
"distribution_bias_score": distribution_bias_score,
|
||||
"distribution_bias_available": distribution_bias["available"],
|
||||
"distribution_preview": distribution_preview[:6],
|
||||
"peak_probability": peak_probability,
|
||||
"peak_value": peak_value,
|
||||
"peak_distance": peak_distance,
|
||||
"peak_alignment_score": peak_alignment_score,
|
||||
"is_peak_candidate": is_peak_candidate,
|
||||
"current_reference": current_reference,
|
||||
"gap_to_target": gap_to_target,
|
||||
"touch_distance": abs(gap_to_target) if gap_to_target is not None else None,
|
||||
@@ -2991,9 +3060,9 @@ class PolymarketReadOnlyLayer:
|
||||
}
|
||||
|
||||
preliminary_rows: List[Dict[str, Any]] = []
|
||||
for entry in market_entries:
|
||||
row_yes = _row_from_entry(entry, "yes")
|
||||
row_no = _row_from_entry(entry, "no")
|
||||
for entry_index, entry in enumerate(market_entries):
|
||||
row_yes = _row_from_entry(entry, "yes", entry_index=entry_index)
|
||||
row_no = _row_from_entry(entry, "no", entry_index=entry_index)
|
||||
if row_yes:
|
||||
preliminary_rows.append(row_yes)
|
||||
if row_no:
|
||||
@@ -3041,9 +3110,9 @@ class PolymarketReadOnlyLayer:
|
||||
entry["spread"] = max(0.0, float(entry["yes_ask"]) - float(entry["yes_bid"]))
|
||||
|
||||
final_rows: List[Dict[str, Any]] = []
|
||||
for entry in market_entries:
|
||||
for entry_index, entry in enumerate(market_entries):
|
||||
for side in ("yes", "no"):
|
||||
row = _row_from_entry(entry, side)
|
||||
row = _row_from_entry(entry, side, entry_index=entry_index)
|
||||
if row:
|
||||
final_rows.append(row)
|
||||
|
||||
@@ -3074,7 +3143,10 @@ class PolymarketReadOnlyLayer:
|
||||
def _passes_mode_filters(row: Dict[str, Any]) -> bool:
|
||||
scan_mode = filters["scan_mode"]
|
||||
if scan_mode == "tradable":
|
||||
return float(row.get("window_score") or 0.0) >= 0.65
|
||||
return (
|
||||
float(row.get("window_score") or 0.0) >= 0.65
|
||||
and bool(row.get("is_peak_candidate"))
|
||||
)
|
||||
if scan_mode == "early":
|
||||
return str(row.get("window_phase") or "") in {"tomorrow", "week_ahead", "early_today"}
|
||||
if scan_mode == "touch":
|
||||
@@ -3095,6 +3167,7 @@ class PolymarketReadOnlyLayer:
|
||||
]
|
||||
filtered_rows.sort(
|
||||
key=lambda row: (
|
||||
1.0 if bool(row.get("is_peak_candidate")) else 0.0,
|
||||
float(row.get("final_score") or 0.0),
|
||||
float(row.get("edge_percent") or 0.0),
|
||||
),
|
||||
|
||||
@@ -675,6 +675,39 @@ def test_distribution_scan_hard_filters_block_unusable_extreme_quotes():
|
||||
assert scan["rows"] == []
|
||||
|
||||
|
||||
def test_distribution_scan_tradable_prefers_peak_bucket_and_adjacent_only():
|
||||
layer, markets = _build_scan_test_layer()
|
||||
|
||||
scan = layer._build_distribution_scan_pack(
|
||||
city_key="wellington",
|
||||
target_date="2026-04-24",
|
||||
primary_market=markets[0],
|
||||
probability_distribution=[
|
||||
{"value": 14, "probability": 20},
|
||||
{"value": 15, "probability": 48},
|
||||
{"value": 16, "probability": 24},
|
||||
{"value": 17, "probability": 8},
|
||||
],
|
||||
temp_symbol="°C",
|
||||
scan_context={
|
||||
"local_date": "2026-04-24",
|
||||
"local_time": "13:10",
|
||||
"peak": {"first_h": 14, "last_h": 16},
|
||||
"current_max_so_far": 13.6,
|
||||
"current_temp": 13.2,
|
||||
"trend": {"recent": []},
|
||||
"network_lead_signal": {},
|
||||
},
|
||||
scan_filters={"limit": 10, "scan_mode": "tradable", "min_edge_pct": 2},
|
||||
)
|
||||
|
||||
assert scan["signal_status"] == "ready"
|
||||
assert scan["primary_signal"]["is_peak_candidate"] is True
|
||||
assert scan["primary_signal"]["peak_distance"] in {0, 1}
|
||||
assert all(bool(row.get("is_peak_candidate")) for row in scan["rows"])
|
||||
assert all((row.get("peak_distance") or 0) <= 1 for row in scan["rows"])
|
||||
|
||||
|
||||
def test_batch_token_market_data_falls_back_to_single_fetch_when_batch_fails():
|
||||
layer = PolymarketReadOnlyLayer()
|
||||
layer._clob_post = lambda *_args, **_kwargs: None
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from web.app import app
|
||||
import web.routes as routes
|
||||
import web.scan_terminal_service as scan_terminal_service
|
||||
from web.scan_terminal_service import _scan_terminal_cache_key
|
||||
from src.database.db_manager import DBManager
|
||||
from src.database.runtime_state import TruthRecordRepository, TrainingFeatureRecordRepository
|
||||
@@ -258,7 +259,66 @@ def test_ops_truth_history_returns_filtered_rows(monkeypatch):
|
||||
assert "items" in payload
|
||||
assert payload["filters"]["city"] == "taipei"
|
||||
assert payload["items"][0]["city"] == "taipei"
|
||||
assert payload["items"][0]["settlement_station_code"] == "RCSS"
|
||||
|
||||
|
||||
def test_scan_terminal_service_returns_stale_payload_after_failed_refresh(monkeypatch):
|
||||
filters = {"scan_mode": "tradable", "limit": 5}
|
||||
normalized_filters = scan_terminal_service._normalize_scan_terminal_filters(filters)
|
||||
scan_terminal_service._SCAN_TERMINAL_CACHE.clear()
|
||||
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_service,
|
||||
"_scan_city_terminal_rows",
|
||||
lambda *_args, **_kwargs: {
|
||||
"city": "taipei",
|
||||
"rows": [
|
||||
{
|
||||
"id": "row-1",
|
||||
"market_key": "market-1",
|
||||
"edge_percent": 12.4,
|
||||
"final_score": 83.0,
|
||||
"volume": 2000,
|
||||
}
|
||||
],
|
||||
"candidate_total": 1,
|
||||
"primary_scores": [83.0],
|
||||
},
|
||||
)
|
||||
|
||||
ready = scan_terminal_service.build_scan_terminal_payload(filters, force_refresh=True)
|
||||
assert ready["status"] == "ready"
|
||||
assert ready["rows"][0]["id"] == "row-1"
|
||||
|
||||
def _explode(*_args, **_kwargs):
|
||||
raise RuntimeError("upstream 504")
|
||||
|
||||
monkeypatch.setattr(scan_terminal_service, "_scan_city_terminal_rows", _explode)
|
||||
|
||||
stale = scan_terminal_service.build_scan_terminal_payload(filters, force_refresh=True)
|
||||
|
||||
assert stale["status"] == "stale"
|
||||
assert stale["stale"] is True
|
||||
assert stale["rows"][0]["id"] == "row-1"
|
||||
assert stale["filters"] == normalized_filters
|
||||
assert stale["stale_reason"] == "upstream 504"
|
||||
|
||||
|
||||
def test_scan_terminal_service_returns_failed_without_success_snapshot(monkeypatch):
|
||||
filters = {"scan_mode": "tradable", "limit": 5}
|
||||
scan_terminal_service._SCAN_TERMINAL_CACHE.clear()
|
||||
|
||||
def _explode(*_args, **_kwargs):
|
||||
raise RuntimeError("network down")
|
||||
|
||||
monkeypatch.setattr(scan_terminal_service, "_scan_city_terminal_rows", _explode)
|
||||
|
||||
failed = scan_terminal_service.build_scan_terminal_payload(filters, force_refresh=True)
|
||||
|
||||
assert failed["status"] == "failed"
|
||||
assert failed["stale"] is False
|
||||
assert failed["rows"] == []
|
||||
assert failed["summary"]["candidate_total"] == 0
|
||||
assert failed["stale_reason"] == "network down"
|
||||
|
||||
|
||||
def test_scan_terminal_endpoint_forwards_filters(monkeypatch):
|
||||
|
||||
+238
-75
@@ -4,6 +4,7 @@ import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import hashlib
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -99,18 +100,125 @@ def _get_cached_scan_terminal_payload(
|
||||
return dict(payload)
|
||||
|
||||
|
||||
def _get_scan_terminal_cache_entry(filters: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
cache_key = _scan_terminal_cache_key(filters)
|
||||
with _SCAN_TERMINAL_CACHE_LOCK:
|
||||
cached = _SCAN_TERMINAL_CACHE.get(cache_key)
|
||||
if not isinstance(cached, dict):
|
||||
return None
|
||||
return dict(cached)
|
||||
|
||||
|
||||
def _build_scan_terminal_snapshot_id(
|
||||
filters: Dict[str, Any],
|
||||
rows: List[Dict[str, Any]],
|
||||
summary: Dict[str, Any],
|
||||
top_signal: Optional[Dict[str, Any]],
|
||||
) -> str:
|
||||
seed_payload = {
|
||||
"filters": filters,
|
||||
"summary": {
|
||||
"candidate_total": summary.get("candidate_total"),
|
||||
"tradable_market_count": summary.get("tradable_market_count"),
|
||||
"avg_edge_percent": summary.get("avg_edge_percent"),
|
||||
},
|
||||
"top_signal": {
|
||||
"id": (top_signal or {}).get("id"),
|
||||
"edge_percent": (top_signal or {}).get("edge_percent"),
|
||||
"final_score": (top_signal or {}).get("final_score"),
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"id": row.get("id"),
|
||||
"edge_percent": row.get("edge_percent"),
|
||||
"final_score": row.get("final_score"),
|
||||
}
|
||||
for row in rows[:10]
|
||||
],
|
||||
}
|
||||
digest = hashlib.md5(
|
||||
json.dumps(seed_payload, ensure_ascii=True, sort_keys=True).encode("utf-8")
|
||||
).hexdigest()
|
||||
return f"scan-{digest[:10]}"
|
||||
|
||||
|
||||
def _set_cached_scan_terminal_payload(
|
||||
filters: Dict[str, Any],
|
||||
payload: Dict[str, Any],
|
||||
) -> None:
|
||||
cache_key = _scan_terminal_cache_key(filters)
|
||||
existing = _get_scan_terminal_cache_entry(filters) or {}
|
||||
with _SCAN_TERMINAL_CACHE_LOCK:
|
||||
_SCAN_TERMINAL_CACHE[cache_key] = {
|
||||
"t": time.time(),
|
||||
"payload": dict(payload),
|
||||
"success_t": time.time(),
|
||||
"success_payload": dict(payload),
|
||||
"last_error": existing.get("last_error"),
|
||||
"last_failed_at": existing.get("last_failed_at"),
|
||||
}
|
||||
|
||||
|
||||
def _set_scan_terminal_failure_state(
|
||||
filters: Dict[str, Any],
|
||||
*,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
cache_key = _scan_terminal_cache_key(filters)
|
||||
with _SCAN_TERMINAL_CACHE_LOCK:
|
||||
existing = _SCAN_TERMINAL_CACHE.get(cache_key) or {}
|
||||
existing["last_error"] = error_message
|
||||
existing["last_failed_at"] = datetime.utcnow().isoformat() + "Z"
|
||||
_SCAN_TERMINAL_CACHE[cache_key] = existing
|
||||
|
||||
|
||||
def _build_stale_scan_terminal_payload(
|
||||
*,
|
||||
filters: Dict[str, Any],
|
||||
success_payload: Dict[str, Any],
|
||||
error_message: str,
|
||||
failed_at: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
payload = dict(success_payload)
|
||||
payload["status"] = "stale"
|
||||
payload["stale"] = True
|
||||
payload["stale_reason"] = error_message
|
||||
payload["last_success_at"] = success_payload.get("generated_at")
|
||||
payload["last_failed_at"] = failed_at
|
||||
payload["filters"] = filters
|
||||
return payload
|
||||
|
||||
|
||||
def _build_failed_scan_terminal_payload(
|
||||
*,
|
||||
filters: Dict[str, Any],
|
||||
error_message: str,
|
||||
failed_at: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"snapshot_id": None,
|
||||
"status": "failed",
|
||||
"stale": False,
|
||||
"stale_reason": error_message,
|
||||
"last_success_at": None,
|
||||
"last_failed_at": failed_at or (datetime.utcnow().isoformat() + "Z"),
|
||||
"filters": filters,
|
||||
"summary": {
|
||||
"recommended_count": 0,
|
||||
"visible_count": 0,
|
||||
"candidate_total": 0,
|
||||
"avg_edge_percent": None,
|
||||
"avg_primary_confidence": None,
|
||||
"tradable_market_count": 0,
|
||||
"total_volume": 0.0,
|
||||
"resolved_market_type": "maxtemp",
|
||||
},
|
||||
"top_signal": None,
|
||||
"rows": [],
|
||||
}
|
||||
|
||||
|
||||
def _resolve_time_range_dates(data: Dict[str, Any], time_range: str) -> List[str]:
|
||||
local_date = str(data.get("local_date") or "").strip()
|
||||
multi_model_daily = data.get("multi_model_daily") or {}
|
||||
@@ -265,83 +373,105 @@ def build_scan_terminal_payload(
|
||||
cached = _get_cached_scan_terminal_payload(filters)
|
||||
if cached is not None:
|
||||
return cached
|
||||
cached_entry = _get_scan_terminal_cache_entry(filters) or {}
|
||||
|
||||
city_names = list(CITIES.keys())
|
||||
max_workers = max(1, min(6, len(city_names)))
|
||||
city_results: List[Dict[str, Any]] = []
|
||||
try:
|
||||
city_names = list(CITIES.keys())
|
||||
max_workers = max(1, min(4, len(city_names)))
|
||||
city_results: List[Dict[str, Any]] = []
|
||||
failed_cities: List[str] = []
|
||||
failed_reasons: List[str] = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_map = {
|
||||
executor.submit(
|
||||
_scan_city_terminal_rows,
|
||||
city_name,
|
||||
filters,
|
||||
force_refresh=force_refresh,
|
||||
): city_name
|
||||
for city_name in city_names
|
||||
}
|
||||
for future in as_completed(future_map):
|
||||
city_name = future_map[future]
|
||||
try:
|
||||
city_results.append(future.result())
|
||||
except Exception as exc:
|
||||
logger.warning("scan terminal city failed city={}: {}", city_name, exc)
|
||||
|
||||
primary_rows: List[Dict[str, Any]] = []
|
||||
primary_scores: List[float] = []
|
||||
candidate_total = 0
|
||||
|
||||
for result in city_results:
|
||||
candidate_total += int(result.get("candidate_total") or 0)
|
||||
primary_rows.extend(result.get("rows") or [])
|
||||
primary_scores.extend(result.get("primary_scores") or [])
|
||||
|
||||
primary_rows.sort(
|
||||
key=lambda row: (
|
||||
float(row.get("final_score") or 0.0),
|
||||
float(row.get("edge_percent") or 0.0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
ranked_rows: List[Dict[str, Any]] = []
|
||||
for index, row in enumerate(primary_rows[: filters["limit"]], start=1):
|
||||
ranked_rows.append(
|
||||
{
|
||||
**row,
|
||||
"rank": index,
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_map = {
|
||||
executor.submit(
|
||||
_scan_city_terminal_rows,
|
||||
city_name,
|
||||
filters,
|
||||
force_refresh=force_refresh,
|
||||
): city_name
|
||||
for city_name in city_names
|
||||
}
|
||||
for future in as_completed(future_map):
|
||||
city_name = future_map[future]
|
||||
try:
|
||||
city_results.append(future.result())
|
||||
except Exception as exc:
|
||||
failed_cities.append(city_name)
|
||||
failed_reasons.append(str(exc))
|
||||
logger.warning("scan terminal city failed city={}: {}", city_name, exc)
|
||||
|
||||
if city_names and len(failed_cities) >= len(city_names):
|
||||
error_message = failed_reasons[0] if failed_reasons else "all city market scans failed"
|
||||
_set_scan_terminal_failure_state(filters, error_message=error_message)
|
||||
failed_entry = _get_scan_terminal_cache_entry(filters) or {}
|
||||
success_payload = failed_entry.get("success_payload")
|
||||
failed_at = failed_entry.get("last_failed_at")
|
||||
if isinstance(success_payload, dict) and success_payload:
|
||||
return _build_stale_scan_terminal_payload(
|
||||
filters=filters,
|
||||
success_payload=success_payload,
|
||||
error_message=error_message,
|
||||
failed_at=failed_at,
|
||||
)
|
||||
return _build_failed_scan_terminal_payload(
|
||||
filters=filters,
|
||||
error_message=error_message,
|
||||
failed_at=failed_at,
|
||||
)
|
||||
|
||||
primary_rows: List[Dict[str, Any]] = []
|
||||
primary_scores: List[float] = []
|
||||
candidate_total = 0
|
||||
|
||||
for result in city_results:
|
||||
candidate_total += int(result.get("candidate_total") or 0)
|
||||
primary_rows.extend(result.get("rows") or [])
|
||||
primary_scores.extend(result.get("primary_scores") or [])
|
||||
|
||||
primary_rows.sort(
|
||||
key=lambda row: (
|
||||
float(row.get("final_score") or 0.0),
|
||||
float(row.get("edge_percent") or 0.0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
unique_market_volume: Dict[str, float] = {}
|
||||
for row in primary_rows:
|
||||
market_key = str(row.get("market_key") or row.get("id") or "").strip()
|
||||
if not market_key:
|
||||
continue
|
||||
unique_market_volume[market_key] = max(
|
||||
unique_market_volume.get(market_key, 0.0),
|
||||
float(row.get("volume") or 0.0),
|
||||
)
|
||||
ranked_rows: List[Dict[str, Any]] = []
|
||||
for index, row in enumerate(primary_rows[: filters["limit"]], start=1):
|
||||
ranked_rows.append(
|
||||
{
|
||||
**row,
|
||||
"rank": index,
|
||||
}
|
||||
)
|
||||
|
||||
avg_edge = None
|
||||
if primary_rows:
|
||||
edge_values = [
|
||||
float(row.get("edge_percent") or 0.0)
|
||||
for row in primary_rows
|
||||
if _safe_float(row.get("edge_percent")) is not None
|
||||
]
|
||||
if edge_values:
|
||||
avg_edge = sum(edge_values) / len(edge_values)
|
||||
unique_market_volume: Dict[str, float] = {}
|
||||
for row in primary_rows:
|
||||
market_key = str(row.get("market_key") or row.get("id") or "").strip()
|
||||
if not market_key:
|
||||
continue
|
||||
unique_market_volume[market_key] = max(
|
||||
unique_market_volume.get(market_key, 0.0),
|
||||
float(row.get("volume") or 0.0),
|
||||
)
|
||||
|
||||
avg_confidence = None
|
||||
if primary_scores:
|
||||
avg_confidence = sum(primary_scores) / len(primary_scores)
|
||||
avg_edge = None
|
||||
if primary_rows:
|
||||
edge_values = [
|
||||
float(row.get("edge_percent") or 0.0)
|
||||
for row in primary_rows
|
||||
if _safe_float(row.get("edge_percent")) is not None
|
||||
]
|
||||
if edge_values:
|
||||
avg_edge = sum(edge_values) / len(edge_values)
|
||||
|
||||
top_signal = ranked_rows[0] if ranked_rows else None
|
||||
payload = {
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"filters": filters,
|
||||
"summary": {
|
||||
avg_confidence = None
|
||||
if primary_scores:
|
||||
avg_confidence = sum(primary_scores) / len(primary_scores)
|
||||
|
||||
top_signal = ranked_rows[0] if ranked_rows else None
|
||||
summary = {
|
||||
"recommended_count": len(primary_rows),
|
||||
"visible_count": len(ranked_rows),
|
||||
"candidate_total": candidate_total,
|
||||
@@ -350,10 +480,43 @@ def build_scan_terminal_payload(
|
||||
"tradable_market_count": len(unique_market_volume),
|
||||
"total_volume": sum(unique_market_volume.values()),
|
||||
"resolved_market_type": "maxtemp",
|
||||
},
|
||||
"top_signal": top_signal,
|
||||
"rows": ranked_rows,
|
||||
}
|
||||
}
|
||||
payload = {
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"filters": filters,
|
||||
"summary": summary,
|
||||
"top_signal": top_signal,
|
||||
"rows": ranked_rows,
|
||||
"status": "ready",
|
||||
"stale": False,
|
||||
"stale_reason": None,
|
||||
"last_success_at": None,
|
||||
"last_failed_at": None,
|
||||
}
|
||||
payload["snapshot_id"] = _build_scan_terminal_snapshot_id(
|
||||
filters,
|
||||
ranked_rows,
|
||||
summary,
|
||||
top_signal,
|
||||
)
|
||||
|
||||
_set_cached_scan_terminal_payload(filters, payload)
|
||||
return payload
|
||||
_set_cached_scan_terminal_payload(filters, payload)
|
||||
return payload
|
||||
except Exception as exc:
|
||||
error_message = str(exc)
|
||||
logger.exception("scan terminal payload build failed: {}", error_message)
|
||||
_set_scan_terminal_failure_state(filters, error_message=error_message)
|
||||
success_payload = cached_entry.get("success_payload")
|
||||
failed_at = _get_scan_terminal_cache_entry(filters).get("last_failed_at") if _get_scan_terminal_cache_entry(filters) else None
|
||||
if isinstance(success_payload, dict) and success_payload:
|
||||
return _build_stale_scan_terminal_payload(
|
||||
filters=filters,
|
||||
success_payload=success_payload,
|
||||
error_message=error_message,
|
||||
failed_at=failed_at,
|
||||
)
|
||||
return _build_failed_scan_terminal_payload(
|
||||
filters=filters,
|
||||
error_message=error_message,
|
||||
failed_at=failed_at,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user