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);
|
border-bottom: 1px solid rgba(118, 146, 188, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-topbar-tabs) {
|
.root :global(.scan-topbar-title) {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 28px;
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-topbar-tab) {
|
.root :global(.scan-topbar-title strong) {
|
||||||
position: relative;
|
margin: 0;
|
||||||
border: none;
|
font-size: 28px;
|
||||||
background: transparent;
|
line-height: 1.08;
|
||||||
color: #b4c8e2;
|
letter-spacing: -0.04em;
|
||||||
font-size: 17px;
|
color: #f3f8ff;
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-topbar-tab.active) {
|
.root :global(.scan-topbar-title span) {
|
||||||
color: #fff;
|
color: #8fa4c3;
|
||||||
}
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
.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-actions) {
|
.root :global(.scan-topbar-actions) {
|
||||||
@@ -9329,8 +9319,6 @@
|
|||||||
.root :global(.scan-ghost-button),
|
.root :global(.scan-ghost-button),
|
||||||
.root :global(.scan-cta-ghost),
|
.root :global(.scan-cta-ghost),
|
||||||
.root :global(.scan-theme-button),
|
.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-action-button),
|
||||||
.root :global(.scan-detail-icon-button),
|
.root :global(.scan-detail-icon-button),
|
||||||
.root :global(.scan-view-all-button) {
|
.root :global(.scan-view-all-button) {
|
||||||
@@ -9386,21 +9374,8 @@
|
|||||||
animation: spin 1s linear infinite;
|
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) {
|
.root :global(.scan-kpi-bar) {
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9425,6 +9400,10 @@
|
|||||||
color: #17d98b;
|
color: #17d98b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-kpi-card.cyan .scan-kpi-label) {
|
||||||
|
color: #37d6ff;
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.scan-kpi-card.purple .scan-kpi-label) {
|
.root :global(.scan-kpi-card.purple .scan-kpi-label) {
|
||||||
color: #a35cff;
|
color: #a35cff;
|
||||||
}
|
}
|
||||||
@@ -9433,6 +9412,14 @@
|
|||||||
color: #49a3ff;
|
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) {
|
.root :global(.scan-kpi-card.orange .scan-kpi-label) {
|
||||||
color: #ffb020;
|
color: #ffb020;
|
||||||
}
|
}
|
||||||
@@ -9487,20 +9474,37 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-list-controls) {
|
.root :global(.scan-list-status) {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-sort-pill) {
|
.root :global(.scan-status-chip) {
|
||||||
min-width: 188px;
|
display: inline-flex;
|
||||||
padding: 12px 14px;
|
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) {
|
.root :global(.scan-status-chip.live) {
|
||||||
width: 48px;
|
border-color: rgba(55, 214, 255, 0.28);
|
||||||
display: grid;
|
background: rgba(55, 214, 255, 0.1);
|
||||||
place-items: center;
|
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) {
|
.root :global(.scan-table-shell) {
|
||||||
@@ -9518,6 +9522,27 @@
|
|||||||
min-height: 340px;
|
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) {
|
.root :global(.scan-table-header) {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 46px minmax(180px, 1.08fr) minmax(132px, 0.72fr) minmax(220px, 1.08fr) minmax(150px, 0.82fr) 92px 82px;
|
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 modelLabel = "EMOS";
|
||||||
const marketLabel = locale === "en-US" ? "P" : "市";
|
const marketLabel = locale === "en-US" ? "Market" : "市场";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scan-distribution-preview">
|
<div className="scan-distribution-preview">
|
||||||
@@ -202,28 +202,53 @@ function ScoreRing({ score }: { score?: number | null }) {
|
|||||||
|
|
||||||
export const OpportunityTable = React.memo(function OpportunityTable({
|
export const OpportunityTable = React.memo(function OpportunityTable({
|
||||||
rows,
|
rows,
|
||||||
|
status,
|
||||||
|
stale,
|
||||||
|
staleReason,
|
||||||
|
loading,
|
||||||
selectedRowId,
|
selectedRowId,
|
||||||
onSelectRow,
|
onSelectRow,
|
||||||
}: {
|
}: {
|
||||||
rows: ScanOpportunityRow[];
|
rows: ScanOpportunityRow[];
|
||||||
|
status?: string | null;
|
||||||
|
stale?: boolean;
|
||||||
|
staleReason?: string | null;
|
||||||
|
loading?: boolean;
|
||||||
selectedRowId?: string | null;
|
selectedRowId?: string | null;
|
||||||
onSelectRow?: (row: ScanOpportunityRow) => void;
|
onSelectRow?: (row: ScanOpportunityRow) => void;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const isEn = locale === "en-US";
|
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 (
|
return (
|
||||||
<div className="scan-table-shell empty">
|
<div className="scan-table-shell empty">
|
||||||
<div className="scan-empty-state">
|
<div className="scan-empty-state">
|
||||||
<div className="scan-empty-title">
|
<div className="scan-empty-title">{title}</div>
|
||||||
{isEn ? "No main signal right now" : "当前无主信号"}
|
<div className="scan-empty-copy">{copy}</div>
|
||||||
</div>
|
|
||||||
<div className="scan-empty-copy">
|
|
||||||
{isEn
|
|
||||||
? "No row passed the price, spread, liquidity, and edge thresholds."
|
|
||||||
: "当前没有机会同时满足价格、点差、流动性和 edge 过滤。"}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -231,12 +256,18 @@ export const OpportunityTable = React.memo(function OpportunityTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scan-table-shell">
|
<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">
|
<div className="scan-table-header">
|
||||||
<span />
|
<span />
|
||||||
<span>{isEn ? "City / Market" : "城市 / 市场"}</span>
|
<span>{isEn ? "City / Market" : "城市 / 市场"}</span>
|
||||||
<span>{isEn ? "Local Time / Phase" : "当前时间 / 阶段"}</span>
|
<span>{isEn ? "Local Time / Phase" : "当前时间 / 阶段"}</span>
|
||||||
<span>{isEn ? "EMOS vs Market" : "EMOS 分布 vs 市场分布"}</span>
|
<span>{isEn ? "EMOS / Market" : "EMOS / 市场"}</span>
|
||||||
<span>{isEn ? "Best Opportunity" : "最佳机会"}</span>
|
<span>{isEn ? "Quote / Model" : "买价 / 模型"}</span>
|
||||||
<span>{isEn ? "Edge" : "边际优势"}</span>
|
<span>{isEn ? "Edge" : "边际优势"}</span>
|
||||||
<span>{isEn ? "Score" : "综合得分"}</span>
|
<span>{isEn ? "Score" : "综合得分"}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,8 +323,8 @@ export const OpportunityTable = React.memo(function OpportunityTable({
|
|||||||
{formatAction(row, locale, tempSymbol)}
|
{formatAction(row, locale, tempSymbol)}
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-trade-sub">
|
<div className="scan-trade-sub">
|
||||||
{formatPercent(row.ask != null ? row.ask * 100 : null)} →{" "}
|
{isEn ? "Buy" : "买价"} {row.ask != null ? `${Math.round(row.ask * 100)}¢` : "--"} ·{" "}
|
||||||
{formatPercent(row.model_probability != null ? row.model_probability * 100 : null)}
|
EMOS {formatPercent(row.model_probability != null ? row.model_probability * 100 : null)}
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-trade-note">
|
<div className="scan-trade-note">
|
||||||
{normalizeTemperatureLabel(row.target_label, tempSymbol) ||
|
{normalizeTemperatureLabel(row.target_label, tempSymbol) ||
|
||||||
|
|||||||
@@ -2,56 +2,99 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
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 formatPercent(value?: number | null, signed = false): string {
|
||||||
|
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||||
function formatVolume(value: number): string {
|
const numeric = Number(value);
|
||||||
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
|
return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
|
||||||
if (value >= 1_000) return `$${(value / 1_000).toFixed(0)}K`;
|
|
||||||
return `$${value.toFixed(0)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { locale } = useI18n();
|
||||||
const isEn = locale === "en-US";
|
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 = [
|
const cards = [
|
||||||
{
|
{
|
||||||
label: isEn ? "Recommended" : "推荐机会",
|
label: isEn ? "Scan Status" : "扫描状态",
|
||||||
value: String(data.recommended_count),
|
value: `${rows.length}/${totalCities || 0}`,
|
||||||
note: `${isEn ? "Visible" : "当前展示"} ${data.visible_count}`,
|
note:
|
||||||
tone: "green",
|
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" : "平均边际优势",
|
label: isEn ? "Book Status" : "盘口状态",
|
||||||
value:
|
value: `${liveRows.length}`,
|
||||||
data.avg_edge_percent != null
|
note: `${isEn ? "Tradable" : "可交易"} ${tradableRows.length} · ${isEn ? "No edge" : "无机会"} ${Math.max(0, rows.length - tradableRows.length)}`,
|
||||||
? `+${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" : "主信号评分",
|
|
||||||
tone: "blue",
|
tone: "blue",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: isEn ? "Tradable Markets" : "可交易市场",
|
label: isEn ? "Opportunity Quality" : "机会质量",
|
||||||
value: String(data.tradable_market_count),
|
value: formatPercent(summary?.avg_edge_percent, true),
|
||||||
note: `${isEn ? "Filtered" : "过滤后"} / ${data.candidate_total || 0}`,
|
note: bestRow
|
||||||
tone: "orange",
|
? `${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" : "总成交量",
|
label: isEn ? "Risk Layers" : "风险层",
|
||||||
value: formatVolume(data.total_volume),
|
value: `${riskCounts.high} / ${riskCounts.medium} / ${riskCounts.low}`,
|
||||||
note: isEn ? "Past 24 hours" : "过去 24 小时",
|
note: `${isEn ? "High / Med / Low" : "高 / 中 / 低"}`,
|
||||||
tone: "neutral",
|
tone: "purple",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Bell,
|
|
||||||
Menu,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
@@ -64,26 +62,8 @@ const DEFAULT_FILTERS: FilterState = {
|
|||||||
limit: 28,
|
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 ContentView = "list" | "map" | "calendar";
|
||||||
type ThemeMode = "dark" | "light";
|
type ThemeMode = "dark" | "light";
|
||||||
const TOP_SECTION_ORDER: TopSection[] = [
|
|
||||||
"terminal",
|
|
||||||
"markets",
|
|
||||||
"analysis",
|
|
||||||
"portfolio",
|
|
||||||
"monitor",
|
|
||||||
"settings",
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatPercent(value?: number | null, signed = false) {
|
function formatPercent(value?: number | null, signed = false) {
|
||||||
if (value == null || Number.isNaN(Number(value))) return "--";
|
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||||
@@ -379,9 +359,6 @@ function DetailPanel({
|
|||||||
{loading ? ` · ${isEn ? "loading" : "载入中"}` : ""}
|
{loading ? ` · ${isEn ? "loading" : "载入中"}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="scan-detail-action-button">
|
|
||||||
{isEn ? "Add Watch" : "添加自选"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="scan-detail-primary-actions">
|
<div className="scan-detail-primary-actions">
|
||||||
@@ -443,18 +420,9 @@ function DetailPanel({
|
|||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-kv">
|
<div className="scan-kv">
|
||||||
<span>{isEn ? "Window Left" : "剩余有效时间"}</span>
|
<span>{isEn ? "Time To Peak" : "距离预测峰值"}</span>
|
||||||
<strong>{formatRemainingWindow(displayRow.remaining_window_minutes, locale)}</strong>
|
<strong>{formatRemainingWindow(displayRow.remaining_window_minutes, locale)}</strong>
|
||||||
</div>
|
</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">
|
<div className="scan-kv">
|
||||||
<span>{isEn ? "Airport" : "机场锚点"}</span>
|
<span>{isEn ? "Airport" : "机场锚点"}</span>
|
||||||
<strong>{localizedAirport || "--"}</strong>
|
<strong>{localizedAirport || "--"}</strong>
|
||||||
@@ -521,38 +489,35 @@ function DetailPanel({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="scan-detail-section">
|
<section className="scan-detail-section">
|
||||||
<div className="scan-trade-cards">
|
<div className="scan-detail-section-title">
|
||||||
<div className="scan-trade-card buy">
|
{isEn ? "Main Signal" : "主信号概况"}
|
||||||
<div className="scan-trade-card-title">
|
</div>
|
||||||
{isEn ? "Buy Yes" : "买入 Yes"}{" "}
|
<div className="scan-kv-list compact">
|
||||||
{normalizeTemperatureLabel(displayRow.target_label, tempSymbol) || ""}
|
<div className="scan-kv">
|
||||||
</div>
|
<span>{isEn ? "Best Side" : "主方向"}</span>
|
||||||
<p>
|
<strong>{displayRow.side === "no" ? "NO" : "YES"}</strong>
|
||||||
{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>
|
</div>
|
||||||
<div className="scan-trade-card sell">
|
<div className="scan-kv">
|
||||||
<div className="scan-trade-card-title">
|
<span>{isEn ? "Best Buy" : "最优买价"}</span>
|
||||||
{isEn ? "Buy No" : "买入 No"}{" "}
|
<strong>
|
||||||
{normalizeTemperatureLabel(displayRow.target_label, tempSymbol) || ""}
|
{displayRow.side === "no"
|
||||||
</div>
|
? formatPrice(getDetailSideAsk(noRow, marketScan, "no"))
|
||||||
<p>
|
: formatPrice(getDetailSideAsk(yesRow, marketScan, "yes"))}
|
||||||
{formatPrice(getDetailSideAsk(noRow, marketScan, "no"))} →{" "}
|
</strong>
|
||||||
{formatProbability(noRow?.model_probability)}
|
</div>
|
||||||
</p>
|
<div className="scan-kv">
|
||||||
<p className={Number(noRow?.edge_percent || 0) >= 0 ? "positive" : "negative"}>
|
<span>{isEn ? "EMOS" : "EMOS 概率"}</span>
|
||||||
{formatPercent(noRow?.edge_percent, true)} {isEn ? "edge" : "边际优势"}
|
<strong>
|
||||||
</p>
|
{displayRow.side === "no"
|
||||||
<p>
|
? formatProbability(noRow?.model_probability)
|
||||||
{isEn ? "Bid / Ask" : "买卖价"} {formatPrice(getDetailSideBid(noRow, marketScan, "no"))} /{" "}
|
: formatProbability(yesRow?.model_probability)}
|
||||||
{formatPrice(getDetailSideAsk(noRow, marketScan, "no"))}
|
</strong>
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -630,6 +595,11 @@ function CalendarView({
|
|||||||
className={`scan-calendar-card ${selectedRowId === row.id ? "selected" : ""}`}
|
className={`scan-calendar-card ${selectedRowId === row.id ? "selected" : ""}`}
|
||||||
onClick={() => onSelectRow(row)}
|
onClick={() => onSelectRow(row)}
|
||||||
>
|
>
|
||||||
|
{(() => {
|
||||||
|
const tempSymbol = row.temp_symbol || "°C";
|
||||||
|
const phaseMeta = getWindowPhaseMeta(row, locale);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<div className="scan-calendar-city">
|
<div className="scan-calendar-city">
|
||||||
{getLocalizedCityName(
|
{getLocalizedCityName(
|
||||||
row.city,
|
row.city,
|
||||||
@@ -637,13 +607,19 @@ function CalendarView({
|
|||||||
locale,
|
locale,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`scan-calendar-action ${row.side === "no" ? "sell" : "buy"}`}>
|
<div className="scan-calendar-action">
|
||||||
{row.action || row.target_label || "--"}
|
{locale === "en-US" ? "DEB high" : "DEB 预测高点"} ·{" "}
|
||||||
|
{row.deb_prediction != null
|
||||||
|
? formatTemperatureValue(row.deb_prediction, tempSymbol)
|
||||||
|
: "--"}
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-calendar-meta">
|
<div className="scan-calendar-meta">
|
||||||
<span>{row.local_time || "--"}</span>
|
<span>{row.local_time || "--"}</span>
|
||||||
<span>{formatPercent(row.edge_percent, true)}</span>
|
<span>{phaseMeta.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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() {
|
function ScanTerminalScreen() {
|
||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
const { locale, toggleLocale } = useI18n();
|
const { locale, toggleLocale } = useI18n();
|
||||||
@@ -776,7 +661,6 @@ function ScanTerminalScreen() {
|
|||||||
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
||||||
const [detailByRowId, setDetailByRowId] = useState<Record<string, MarketScan | null>>({});
|
const [detailByRowId, setDetailByRowId] = useState<Record<string, MarketScan | null>>({});
|
||||||
const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null);
|
const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null);
|
||||||
const [activeSection, setActiveSection] = useState<TopSection>("terminal");
|
|
||||||
const [activeView, setActiveView] = useState<ContentView>("list");
|
const [activeView, setActiveView] = useState<ContentView>("list");
|
||||||
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
||||||
const [userLocalTime, setUserLocalTime] = useState("--");
|
const [userLocalTime, setUserLocalTime] = useState("--");
|
||||||
@@ -806,12 +690,12 @@ function ScanTerminalScreen() {
|
|||||||
|
|
||||||
const fetchTerminal = async (filters: FilterState, force = false) => {
|
const fetchTerminal = async (filters: FilterState, force = false) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const response = await dashboardClient.getScanTerminal(filters, { force });
|
const response = await dashboardClient.getScanTerminal(filters, { force });
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setTerminalData(response);
|
setTerminalData(response);
|
||||||
setActiveFilters(filters);
|
setActiveFilters(filters);
|
||||||
|
setError(response.status === "failed" ? response.stale_reason || null : null);
|
||||||
setSelectedRowId((current) => {
|
setSelectedRowId((current) => {
|
||||||
if (current && response.rows.some((row) => row.id === current)) {
|
if (current && response.rows.some((row) => row.id === current)) {
|
||||||
return current;
|
return current;
|
||||||
@@ -827,7 +711,7 @@ function ScanTerminalScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchDetail = async (row: ScanOpportunityRow) => {
|
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;
|
if (detailByRowId[row.id] !== undefined) return;
|
||||||
setDetailLoadingId(row.id);
|
setDetailLoadingId(row.id);
|
||||||
try {
|
try {
|
||||||
@@ -880,15 +764,13 @@ function ScanTerminalScreen() {
|
|||||||
window.localStorage.setItem("polyweather_scan_theme", themeMode);
|
window.localStorage.setItem("polyweather_scan_theme", themeMode);
|
||||||
}, [themeMode]);
|
}, [themeMode]);
|
||||||
|
|
||||||
|
const resolvedView: ContentView = activeView;
|
||||||
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
|
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
|
||||||
const resolvedView: ContentView =
|
|
||||||
activeSection === "markets"
|
|
||||||
? "map"
|
|
||||||
: activeSection === "analysis"
|
|
||||||
? "calendar"
|
|
||||||
: activeView;
|
|
||||||
const activeDetailRow = resolvedView === "map" && mapFocusedCity ? mapFocusedRow : selectedRow;
|
const activeDetailRow = resolvedView === "map" && mapFocusedCity ? mapFocusedRow : selectedRow;
|
||||||
const selectedDetail = activeDetailRow ? detailByRowId[activeDetailRow.id] : null;
|
const selectedDetail = activeDetailRow ? detailByRowId[activeDetailRow.id] : null;
|
||||||
|
const scanStatus = terminalData?.status || (loading ? "loading" : error ? "failed" : "ready");
|
||||||
|
const staleReason =
|
||||||
|
terminalData?.stale_reason || error || null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeDetailRow) return;
|
if (!activeDetailRow) return;
|
||||||
@@ -914,15 +796,6 @@ function ScanTerminalScreen() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderMainView = () => {
|
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") {
|
if (resolvedView === "map") {
|
||||||
return (
|
return (
|
||||||
<div className="scan-map-view">
|
<div className="scan-map-view">
|
||||||
@@ -951,6 +824,10 @@ function ScanTerminalScreen() {
|
|||||||
<>
|
<>
|
||||||
<OpportunityTable
|
<OpportunityTable
|
||||||
rows={timeSortedRows}
|
rows={timeSortedRows}
|
||||||
|
status={scanStatus}
|
||||||
|
stale={Boolean(terminalData?.stale)}
|
||||||
|
staleReason={staleReason}
|
||||||
|
loading={loading}
|
||||||
selectedRowId={selectedRowId}
|
selectedRowId={selectedRowId}
|
||||||
onSelectRow={handleSelectRow}
|
onSelectRow={handleSelectRow}
|
||||||
/>
|
/>
|
||||||
@@ -973,19 +850,21 @@ function ScanTerminalScreen() {
|
|||||||
|
|
||||||
<main className="scan-data-grid">
|
<main className="scan-data-grid">
|
||||||
<div className="scan-topbar">
|
<div className="scan-topbar">
|
||||||
<div className="scan-topbar-tabs">
|
<div className="scan-topbar-title">
|
||||||
{NAV_ITEMS.map((item, index) => (
|
<strong>{isEn ? "Market Scan Terminal" : "市场扫描台"}</strong>
|
||||||
<button
|
<span>
|
||||||
key={item.zh}
|
{loading
|
||||||
type="button"
|
? isEn
|
||||||
className={`scan-topbar-tab ${
|
? "Refreshing current market snapshot"
|
||||||
TOP_SECTION_ORDER[index] === activeSection ? "active" : ""
|
: "正在刷新当前市场快照"
|
||||||
}`}
|
: terminalData?.stale
|
||||||
onClick={() => setActiveSection(TOP_SECTION_ORDER[index])}
|
? isEn
|
||||||
>
|
? "Showing the last successful snapshot"
|
||||||
{isEn ? item.en : item.zh}
|
: "当前显示上次成功快照"
|
||||||
</button>
|
: isEn
|
||||||
))}
|
? "Read-only market scan with peak-first main signal"
|
||||||
|
: "只读市场扫描,主信号按 EMOS 主峰优先"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-topbar-actions">
|
<div className="scan-topbar-actions">
|
||||||
<button
|
<button
|
||||||
@@ -1012,11 +891,7 @@ function ScanTerminalScreen() {
|
|||||||
</button>
|
</button>
|
||||||
<button type="button" className="scan-ghost-button" onClick={() => void fetchTerminal(activeFilters, true)}>
|
<button type="button" className="scan-ghost-button" onClick={() => void fetchTerminal(activeFilters, true)}>
|
||||||
<RefreshCw size={14} className={loading ? "spin" : undefined} />
|
<RefreshCw size={14} className={loading ? "spin" : undefined} />
|
||||||
{isEn ? "Refresh" : "筛选"}
|
{isEn ? "Refresh" : "刷新"}
|
||||||
</button>
|
|
||||||
<button type="button" className="scan-cta-ghost">
|
|
||||||
<Bell size={14} />
|
|
||||||
{isEn ? "Custom Alerts" : "自定义提醒"}
|
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={accountHref}
|
href={accountHref}
|
||||||
@@ -1029,28 +904,11 @@ function ScanTerminalScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<ScanKPIBar
|
||||||
data={
|
response={terminalData}
|
||||||
terminalData?.summary || {
|
rows={timeSortedRows}
|
||||||
recommended_count: 0,
|
totalCities={store.cities.length}
|
||||||
visible_count: 0,
|
loading={loading}
|
||||||
candidate_total: 0,
|
|
||||||
avg_edge_percent: null,
|
|
||||||
avg_primary_confidence: null,
|
|
||||||
tradable_market_count: 0,
|
|
||||||
total_volume: 0,
|
|
||||||
resolved_market_type: "maxtemp",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="scan-list-section">
|
<section className="scan-list-section">
|
||||||
@@ -1058,9 +916,8 @@ function ScanTerminalScreen() {
|
|||||||
<div className="scan-list-tabs">
|
<div className="scan-list-tabs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={activeSection === "terminal" && resolvedView === "list" ? "active" : ""}
|
className={resolvedView === "list" ? "active" : ""}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveSection("terminal");
|
|
||||||
setActiveView("list");
|
setActiveView("list");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1070,7 +927,6 @@ function ScanTerminalScreen() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={resolvedView === "map" ? "active" : ""}
|
className={resolvedView === "map" ? "active" : ""}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveSection("terminal");
|
|
||||||
setActiveView("map");
|
setActiveView("map");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1080,39 +936,32 @@ function ScanTerminalScreen() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={resolvedView === "calendar" ? "active" : ""}
|
className={resolvedView === "calendar" ? "active" : ""}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveSection("terminal");
|
|
||||||
setActiveView("calendar");
|
setActiveView("calendar");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isEn ? "Calendar View" : "日历视图"}
|
{isEn ? "Calendar View" : "日历视图"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-list-controls">
|
<div className="scan-list-status">
|
||||||
<div className="scan-sort-pill">
|
{terminalData?.stale ? (
|
||||||
{isEn
|
<span className="scan-status-chip stale">
|
||||||
? resolvedView === "calendar"
|
{isEn ? "Delayed snapshot" : "延迟快照"}
|
||||||
? "Sort: Date"
|
</span>
|
||||||
: resolvedView === "map"
|
) : null}
|
||||||
? "Sort: City Map"
|
{loading ? (
|
||||||
: "Sort: Your Time"
|
<span className="scan-status-chip live">
|
||||||
: resolvedView === "calendar"
|
{isEn ? "Refreshing" : "刷新中"}
|
||||||
? "排序:日期"
|
</span>
|
||||||
: resolvedView === "map"
|
) : null}
|
||||||
? "排序:地图"
|
|
||||||
: "排序:用户时区时间"}
|
|
||||||
</div>
|
|
||||||
<button type="button" className="scan-icon-pill" aria-label="menu">
|
|
||||||
<Menu size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{scanStatus === "failed" && !terminalData ? (
|
||||||
<div className="scan-empty-state">
|
<div className="scan-empty-state">
|
||||||
<div className="scan-empty-title">
|
<div className="scan-empty-title">
|
||||||
{isEn ? "Scan failed" : "扫描失败"}
|
{isEn ? "Scan failed" : "扫描失败"}
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-empty-copy">{error}</div>
|
<div className="scan-empty-copy">{staleReason}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
renderMainView()
|
renderMainView()
|
||||||
|
|||||||
@@ -497,6 +497,11 @@ export interface ScanOpportunityRow {
|
|||||||
distribution_bias_direction?: string | null;
|
distribution_bias_direction?: string | null;
|
||||||
distribution_bias_score?: number | null;
|
distribution_bias_score?: number | null;
|
||||||
distribution_bias_available?: boolean;
|
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_phase?: string | null;
|
||||||
window_score?: number | null;
|
window_score?: number | null;
|
||||||
remaining_window_minutes?: number | null;
|
remaining_window_minutes?: number | null;
|
||||||
@@ -525,6 +530,12 @@ export interface PrimarySignal extends ScanOpportunityRow {}
|
|||||||
|
|
||||||
export interface ScanTerminalResponse {
|
export interface ScanTerminalResponse {
|
||||||
generated_at: string;
|
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;
|
filters: ScanTerminalFilters;
|
||||||
summary: {
|
summary: {
|
||||||
recommended_count: number;
|
recommended_count: number;
|
||||||
|
|||||||
@@ -2810,12 +2810,60 @@ class PolymarketReadOnlyLayer:
|
|||||||
)
|
)
|
||||||
distribution_preview[highlighted_index]["highlighted"] = True
|
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(
|
current_reference_raw = _safe_float(
|
||||||
(scan_context or {}).get("current_max_so_far")
|
(scan_context or {}).get("current_max_so_far")
|
||||||
or (scan_context or {}).get("current_temp")
|
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")))
|
model_event_probability = _clamp_probability(_safe_float(entry.get("model_event_probability")))
|
||||||
market_event_probability = _clamp_probability(_safe_float(entry.get("market_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")))
|
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
|
if target_threshold is not None and current_reference is not None
|
||||||
else 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(
|
temperature_direction = self._resolve_temperature_direction(
|
||||||
side=side,
|
side=side,
|
||||||
market_direction=str(entry.get("market_direction") or "exact"),
|
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.20 * float(window_meta.get("score") or 0.0)
|
||||||
+ 0.10 * liquidity_score
|
+ 0.10 * liquidity_score
|
||||||
+ 0.10 * price_usefulness_score
|
+ 0.10 * price_usefulness_score
|
||||||
|
+ 0.08 * peak_alignment_score
|
||||||
) - spread_penalty
|
) - spread_penalty
|
||||||
market_slug = str(market.get("slug") or "").strip()
|
market_slug = str(market.get("slug") or "").strip()
|
||||||
target_label = str(entry.get("target_label") 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_score": distribution_bias_score,
|
||||||
"distribution_bias_available": distribution_bias["available"],
|
"distribution_bias_available": distribution_bias["available"],
|
||||||
"distribution_preview": distribution_preview[:6],
|
"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,
|
"current_reference": current_reference,
|
||||||
"gap_to_target": gap_to_target,
|
"gap_to_target": gap_to_target,
|
||||||
"touch_distance": abs(gap_to_target) if gap_to_target is not None else None,
|
"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]] = []
|
preliminary_rows: List[Dict[str, Any]] = []
|
||||||
for entry in market_entries:
|
for entry_index, entry in enumerate(market_entries):
|
||||||
row_yes = _row_from_entry(entry, "yes")
|
row_yes = _row_from_entry(entry, "yes", entry_index=entry_index)
|
||||||
row_no = _row_from_entry(entry, "no")
|
row_no = _row_from_entry(entry, "no", entry_index=entry_index)
|
||||||
if row_yes:
|
if row_yes:
|
||||||
preliminary_rows.append(row_yes)
|
preliminary_rows.append(row_yes)
|
||||||
if row_no:
|
if row_no:
|
||||||
@@ -3041,9 +3110,9 @@ class PolymarketReadOnlyLayer:
|
|||||||
entry["spread"] = max(0.0, float(entry["yes_ask"]) - float(entry["yes_bid"]))
|
entry["spread"] = max(0.0, float(entry["yes_ask"]) - float(entry["yes_bid"]))
|
||||||
|
|
||||||
final_rows: List[Dict[str, Any]] = []
|
final_rows: List[Dict[str, Any]] = []
|
||||||
for entry in market_entries:
|
for entry_index, entry in enumerate(market_entries):
|
||||||
for side in ("yes", "no"):
|
for side in ("yes", "no"):
|
||||||
row = _row_from_entry(entry, side)
|
row = _row_from_entry(entry, side, entry_index=entry_index)
|
||||||
if row:
|
if row:
|
||||||
final_rows.append(row)
|
final_rows.append(row)
|
||||||
|
|
||||||
@@ -3074,7 +3143,10 @@ class PolymarketReadOnlyLayer:
|
|||||||
def _passes_mode_filters(row: Dict[str, Any]) -> bool:
|
def _passes_mode_filters(row: Dict[str, Any]) -> bool:
|
||||||
scan_mode = filters["scan_mode"]
|
scan_mode = filters["scan_mode"]
|
||||||
if scan_mode == "tradable":
|
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":
|
if scan_mode == "early":
|
||||||
return str(row.get("window_phase") or "") in {"tomorrow", "week_ahead", "early_today"}
|
return str(row.get("window_phase") or "") in {"tomorrow", "week_ahead", "early_today"}
|
||||||
if scan_mode == "touch":
|
if scan_mode == "touch":
|
||||||
@@ -3095,6 +3167,7 @@ class PolymarketReadOnlyLayer:
|
|||||||
]
|
]
|
||||||
filtered_rows.sort(
|
filtered_rows.sort(
|
||||||
key=lambda row: (
|
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("final_score") or 0.0),
|
||||||
float(row.get("edge_percent") 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"] == []
|
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():
|
def test_batch_token_market_data_falls_back_to_single_fetch_when_batch_fails():
|
||||||
layer = PolymarketReadOnlyLayer()
|
layer = PolymarketReadOnlyLayer()
|
||||||
layer._clob_post = lambda *_args, **_kwargs: None
|
layer._clob_post = lambda *_args, **_kwargs: None
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi.testclient import TestClient
|
|||||||
|
|
||||||
from web.app import app
|
from web.app import app
|
||||||
import web.routes as routes
|
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 web.scan_terminal_service import _scan_terminal_cache_key
|
||||||
from src.database.db_manager import DBManager
|
from src.database.db_manager import DBManager
|
||||||
from src.database.runtime_state import TruthRecordRepository, TrainingFeatureRecordRepository
|
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 "items" in payload
|
||||||
assert payload["filters"]["city"] == "taipei"
|
assert payload["filters"]["city"] == "taipei"
|
||||||
assert payload["items"][0]["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):
|
def test_scan_terminal_endpoint_forwards_filters(monkeypatch):
|
||||||
|
|||||||
+238
-75
@@ -4,6 +4,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import hashlib
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
@@ -99,18 +100,125 @@ def _get_cached_scan_terminal_payload(
|
|||||||
return dict(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(
|
def _set_cached_scan_terminal_payload(
|
||||||
filters: Dict[str, Any],
|
filters: Dict[str, Any],
|
||||||
payload: Dict[str, Any],
|
payload: Dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
cache_key = _scan_terminal_cache_key(filters)
|
cache_key = _scan_terminal_cache_key(filters)
|
||||||
|
existing = _get_scan_terminal_cache_entry(filters) or {}
|
||||||
with _SCAN_TERMINAL_CACHE_LOCK:
|
with _SCAN_TERMINAL_CACHE_LOCK:
|
||||||
_SCAN_TERMINAL_CACHE[cache_key] = {
|
_SCAN_TERMINAL_CACHE[cache_key] = {
|
||||||
"t": time.time(),
|
"t": time.time(),
|
||||||
"payload": dict(payload),
|
"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]:
|
def _resolve_time_range_dates(data: Dict[str, Any], time_range: str) -> List[str]:
|
||||||
local_date = str(data.get("local_date") or "").strip()
|
local_date = str(data.get("local_date") or "").strip()
|
||||||
multi_model_daily = data.get("multi_model_daily") or {}
|
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)
|
cached = _get_cached_scan_terminal_payload(filters)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
cached_entry = _get_scan_terminal_cache_entry(filters) or {}
|
||||||
|
|
||||||
city_names = list(CITIES.keys())
|
try:
|
||||||
max_workers = max(1, min(6, len(city_names)))
|
city_names = list(CITIES.keys())
|
||||||
city_results: List[Dict[str, Any]] = []
|
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:
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
future_map = {
|
future_map = {
|
||||||
executor.submit(
|
executor.submit(
|
||||||
_scan_city_terminal_rows,
|
_scan_city_terminal_rows,
|
||||||
city_name,
|
city_name,
|
||||||
filters,
|
filters,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
): city_name
|
): city_name
|
||||||
for city_name in city_names
|
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,
|
|
||||||
}
|
}
|
||||||
|
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] = {}
|
ranked_rows: List[Dict[str, Any]] = []
|
||||||
for row in primary_rows:
|
for index, row in enumerate(primary_rows[: filters["limit"]], start=1):
|
||||||
market_key = str(row.get("market_key") or row.get("id") or "").strip()
|
ranked_rows.append(
|
||||||
if not market_key:
|
{
|
||||||
continue
|
**row,
|
||||||
unique_market_volume[market_key] = max(
|
"rank": index,
|
||||||
unique_market_volume.get(market_key, 0.0),
|
}
|
||||||
float(row.get("volume") or 0.0),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
avg_edge = None
|
unique_market_volume: Dict[str, float] = {}
|
||||||
if primary_rows:
|
for row in primary_rows:
|
||||||
edge_values = [
|
market_key = str(row.get("market_key") or row.get("id") or "").strip()
|
||||||
float(row.get("edge_percent") or 0.0)
|
if not market_key:
|
||||||
for row in primary_rows
|
continue
|
||||||
if _safe_float(row.get("edge_percent")) is not None
|
unique_market_volume[market_key] = max(
|
||||||
]
|
unique_market_volume.get(market_key, 0.0),
|
||||||
if edge_values:
|
float(row.get("volume") or 0.0),
|
||||||
avg_edge = sum(edge_values) / len(edge_values)
|
)
|
||||||
|
|
||||||
avg_confidence = None
|
avg_edge = None
|
||||||
if primary_scores:
|
if primary_rows:
|
||||||
avg_confidence = sum(primary_scores) / len(primary_scores)
|
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
|
avg_confidence = None
|
||||||
payload = {
|
if primary_scores:
|
||||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
avg_confidence = sum(primary_scores) / len(primary_scores)
|
||||||
"filters": filters,
|
|
||||||
"summary": {
|
top_signal = ranked_rows[0] if ranked_rows else None
|
||||||
|
summary = {
|
||||||
"recommended_count": len(primary_rows),
|
"recommended_count": len(primary_rows),
|
||||||
"visible_count": len(ranked_rows),
|
"visible_count": len(ranked_rows),
|
||||||
"candidate_total": candidate_total,
|
"candidate_total": candidate_total,
|
||||||
@@ -350,10 +480,43 @@ def build_scan_terminal_payload(
|
|||||||
"tradable_market_count": len(unique_market_volume),
|
"tradable_market_count": len(unique_market_volume),
|
||||||
"total_volume": sum(unique_market_volume.values()),
|
"total_volume": sum(unique_market_volume.values()),
|
||||||
"resolved_market_type": "maxtemp",
|
"resolved_market_type": "maxtemp",
|
||||||
},
|
}
|
||||||
"top_signal": top_signal,
|
payload = {
|
||||||
"rows": ranked_rows,
|
"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)
|
_set_cached_scan_terminal_payload(filters, payload)
|
||||||
return 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