Redesign the scan terminal layout for EMOS
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -6,90 +6,125 @@ import { useI18n } from "@/hooks/useI18n";
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||
|
||||
function getStatusMeta(
|
||||
row: ScanOpportunityRow,
|
||||
locale: string,
|
||||
): { label: string; tone: "green" | "amber" | "purple" | "neutral" } {
|
||||
const phase = String(row.window_phase || "").toLowerCase();
|
||||
if (phase === "active_peak" || phase === "setup_today") {
|
||||
return { label: locale === "en-US" ? "Tradable" : "可交易", tone: "green" };
|
||||
}
|
||||
if (phase === "tomorrow" || phase === "week_ahead") {
|
||||
return { label: locale === "en-US" ? "Early" : "早期机会", tone: "purple" };
|
||||
}
|
||||
if (phase === "post_peak") {
|
||||
return { label: locale === "en-US" ? "Post Peak" : "峰后确认", tone: "amber" };
|
||||
}
|
||||
return { label: locale === "en-US" ? "Watching" : "观察中", tone: "neutral" };
|
||||
}
|
||||
|
||||
function formatPercent(value?: number | null, signed = false) {
|
||||
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||
const numeric = Number(value);
|
||||
if (!signed) return `${numeric.toFixed(1)}%`;
|
||||
return `${numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
|
||||
return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatTimeBlock(row: ScanOpportunityRow, locale: string) {
|
||||
const parts: string[] = [];
|
||||
if (row.local_time) {
|
||||
parts.push(row.local_time);
|
||||
function formatVolume(value?: number | null) {
|
||||
const numeric = Number(value || 0);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return "--";
|
||||
if (numeric >= 1_000_000) return `$${(numeric / 1_000_000).toFixed(1)}M`;
|
||||
if (numeric >= 1_000) return `$${(numeric / 1_000).toFixed(0)}K`;
|
||||
return `$${numeric.toFixed(0)}`;
|
||||
}
|
||||
|
||||
function formatWindowMinutes(value: number | null | undefined, locale: string) {
|
||||
if (value == null || !Number.isFinite(Number(value))) return "--";
|
||||
const minutes = Math.max(0, Math.round(Number(value)));
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remains = minutes % 60;
|
||||
if (locale === "en-US") {
|
||||
if (hours <= 0) return `${remains}m left`;
|
||||
return `${hours}h ${remains}m left`;
|
||||
}
|
||||
if (row.selected_date) {
|
||||
parts.push(row.selected_date);
|
||||
}
|
||||
return parts.join(" · ") || (locale === "en-US" ? "No time" : "暂无时间");
|
||||
if (hours <= 0) return `剩余 ${remains} 分钟`;
|
||||
return `剩余 ${hours}h ${remains}m`;
|
||||
}
|
||||
|
||||
function formatAction(row: ScanOpportunityRow, locale: string) {
|
||||
if (row.action) return row.action;
|
||||
if (row.side === "yes") {
|
||||
return `${locale === "en-US" ? "Buy" : "买入"} Yes ${row.target_label || ""}`.trim();
|
||||
return `${locale === "en-US" ? "Buy Yes" : "买入 Yes"} ${row.target_label || ""}`.trim();
|
||||
}
|
||||
if (row.side === "no") {
|
||||
return `${locale === "en-US" ? "Buy" : "买入"} No ${row.target_label || ""}`.trim();
|
||||
return `${locale === "en-US" ? "Buy No" : "买入 No"} ${row.target_label || ""}`.trim();
|
||||
}
|
||||
return row.action || "--";
|
||||
return "--";
|
||||
}
|
||||
|
||||
function formatProbability(value?: number | null) {
|
||||
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||
return `${(Number(value) * 100).toFixed(0)}%`;
|
||||
function getPhaseMeta(
|
||||
row: ScanOpportunityRow,
|
||||
locale: string,
|
||||
): { label: string; tone: "green" | "amber" | "blue" | "red" } {
|
||||
const mode = String(row.window_phase || "").toLowerCase();
|
||||
if (mode === "active_peak" || mode === "setup_today") {
|
||||
return {
|
||||
label: locale === "en-US" ? "Touch Play" : "触达博弈",
|
||||
tone: "red",
|
||||
};
|
||||
}
|
||||
if (mode === "tomorrow" || mode === "week_ahead") {
|
||||
return {
|
||||
label: locale === "en-US" ? "Early" : "早期机会",
|
||||
tone: "blue",
|
||||
};
|
||||
}
|
||||
if (row.trend_alignment) {
|
||||
return {
|
||||
label: locale === "en-US" ? "Trend" : "趋势确认",
|
||||
tone: "amber",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: locale === "en-US" ? "Tradable" : "可交易",
|
||||
tone: "green",
|
||||
};
|
||||
}
|
||||
|
||||
function scoreTone(score?: number | null) {
|
||||
const numeric = Number(score || 0);
|
||||
if (numeric >= 85) return "green";
|
||||
if (numeric >= 70) return "yellow";
|
||||
return "gray";
|
||||
}
|
||||
|
||||
function ProbabilityPreview({
|
||||
row,
|
||||
locale,
|
||||
}: {
|
||||
row: ScanOpportunityRow;
|
||||
locale: string;
|
||||
}) {
|
||||
const targetBase =
|
||||
row.target_value ??
|
||||
row.target_threshold ??
|
||||
row.target_lower ??
|
||||
row.target_upper ??
|
||||
null;
|
||||
const unit = row.target_unit || row.temp_symbol || "";
|
||||
const targetLabel =
|
||||
targetBase != null
|
||||
? `${Math.round(Number(targetBase))}${unit}`
|
||||
: row.target_label || "--";
|
||||
return (
|
||||
<div className="scan-distribution-preview">
|
||||
<div className="scan-distribution-card featured">
|
||||
<strong>{targetLabel}</strong>
|
||||
<span>{locale === "en-US" ? "Target" : "目标"}</span>
|
||||
</div>
|
||||
<div className="scan-distribution-card">
|
||||
<strong>{formatPercent(row.model_event_probability != null ? row.model_event_probability * 100 : null)}</strong>
|
||||
<span>{locale === "en-US" ? "Model" : "模型"}</span>
|
||||
</div>
|
||||
<div className="scan-distribution-card">
|
||||
<strong>{formatPercent(row.market_event_probability != null ? row.market_event_probability * 100 : null)}</strong>
|
||||
<span>{locale === "en-US" ? "Market" : "市场"}</span>
|
||||
</div>
|
||||
<div className="scan-distribution-card">
|
||||
<strong>{formatPercent(row.distribution_bias_score)}</strong>
|
||||
<span>{row.distribution_bias_direction || (locale === "en-US" ? "Bias" : "偏移")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreRing({ score }: { score?: number | null }) {
|
||||
const displayScore = Math.max(0, Math.min(100, Number(score || 0)));
|
||||
const radius = 20;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const progress = (displayScore / 100) * circumference;
|
||||
const color =
|
||||
displayScore >= 85 ? "#00E0A4" : displayScore >= 70 ? "#FFB020" : "#FF4D6A";
|
||||
|
||||
return (
|
||||
<div className="scan-score-ring">
|
||||
<svg viewBox="0 0 48 48" width={48} height={48}>
|
||||
<circle
|
||||
cx={24}
|
||||
cy={24}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.06)"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<circle
|
||||
cx={24}
|
||||
cy={24}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={3}
|
||||
strokeDasharray={`${progress} ${circumference - progress}`}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 24 24)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="scan-score-value" style={{ color }}>
|
||||
{displayScore.toFixed(0)}
|
||||
</span>
|
||||
<div className={`scan-score-ring tone-${scoreTone(displayScore)}`}>
|
||||
<span>{displayScore.toFixed(0)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -106,124 +141,111 @@ export function OpportunityTable({
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
return (
|
||||
<div className="scan-table-container">
|
||||
<div className="scan-table-header">
|
||||
<span className="scan-th scan-th-rank">#</span>
|
||||
<span className="scan-th scan-th-city">
|
||||
{isEn ? "City / Market" : "城市 / 市场"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-time">
|
||||
{isEn ? "Time / Phase" : "时间 / 阶段"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-prob">
|
||||
{isEn ? "EMOS vs Market" : "EMOS vs 市场"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-action">
|
||||
{isEn ? "Best Action" : "最佳动作"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-edge">
|
||||
{isEn ? "Edge" : "边际优势"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-score">
|
||||
{isEn ? "Score" : "综合得分"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-fav" />
|
||||
</div>
|
||||
|
||||
{rows.map((row, index) => {
|
||||
const status = getStatusMeta(row, locale);
|
||||
const localizedCityName = getLocalizedCityName(
|
||||
row.city,
|
||||
row.city_display_name || row.display_name || row.city,
|
||||
locale,
|
||||
);
|
||||
const selected = selectedRowId === row.id;
|
||||
const finalScore = Number(row.final_score || 0);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
className={`scan-table-row ${selected ? "selected" : ""} ${row.tradable ? "tradable" : ""}`}
|
||||
onClick={() => onSelectRow?.(row)}
|
||||
>
|
||||
<span className={`scan-td scan-td-rank rank-${status.tone}`}>
|
||||
<span className="scan-rank-circle">{row.rank || index + 1}</span>
|
||||
</span>
|
||||
|
||||
<span className="scan-td scan-td-city">
|
||||
<span className="scan-city-thumb">
|
||||
<span className="scan-city-img-placeholder" />
|
||||
</span>
|
||||
<span className="scan-city-info">
|
||||
<span className="scan-city-name">{localizedCityName}</span>
|
||||
<span className="scan-city-sub">
|
||||
{row.target_label || row.market_question || "--"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="scan-td scan-td-time">
|
||||
<span className="scan-time-text">{formatTimeBlock(row, locale)}</span>
|
||||
<span className={`scan-status-badge tone-${status.tone}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="scan-td scan-td-prob">
|
||||
<div className="scan-city-info">
|
||||
<span className="scan-city-name">
|
||||
{formatProbability(row.model_event_probability)} /{" "}
|
||||
{formatProbability(row.market_event_probability)}
|
||||
</span>
|
||||
<span className="scan-city-sub">
|
||||
{isEn ? "Bias" : "偏移"}{" "}
|
||||
{row.distribution_bias_direction || "--"} ·{" "}
|
||||
{formatPercent(row.distribution_bias_score)}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<span className="scan-td scan-td-action">
|
||||
<span className="scan-action-text">
|
||||
{formatAction(row, locale)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="scan-td scan-td-edge">
|
||||
<span
|
||||
className={`scan-edge-value ${finalScore > 0 ? "positive" : "neutral"}`}
|
||||
>
|
||||
{formatPercent(row.edge_percent, true)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="scan-td scan-td-score">
|
||||
<ScoreRing score={row.final_score} />
|
||||
</span>
|
||||
|
||||
<span className="scan-td scan-td-fav">
|
||||
<Star size={16} className="scan-fav-icon" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{!rows.length ? (
|
||||
<div className="scan-detail-empty">
|
||||
<div>
|
||||
<div className="scan-detail-section-title">
|
||||
{isEn ? "No primary signal" : "当前无主信号"}
|
||||
</div>
|
||||
<p className="scan-city-sub">
|
||||
{isEn
|
||||
? "No opportunity passed the price, spread, liquidity, and edge filters."
|
||||
: "当前没有机会同时满足价格、点差、流动性和 edge 过滤。"}
|
||||
</p>
|
||||
if (!rows.length) {
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="scan-table-shell">
|
||||
<div className="scan-table-header">
|
||||
<span />
|
||||
<span>{isEn ? "City / Market" : "城市 / 市场"}</span>
|
||||
<span>{isEn ? "Local Time / Phase" : "当前时间 / 阶段"}</span>
|
||||
<span>{isEn ? "Model vs Market" : "模型分布 vs 市场分布"}</span>
|
||||
<span>{isEn ? "Best Opportunity" : "最佳机会"}</span>
|
||||
<span>{isEn ? "Edge" : "边际优势"}</span>
|
||||
<span>{isEn ? "Score" : "综合得分"}</span>
|
||||
</div>
|
||||
|
||||
<div className="scan-table-body">
|
||||
{rows.map((row, index) => {
|
||||
const phaseMeta = getPhaseMeta(row, locale);
|
||||
const localizedCityName = getLocalizedCityName(
|
||||
row.city,
|
||||
row.city_display_name || row.display_name || row.city,
|
||||
locale,
|
||||
);
|
||||
const selected = selectedRowId === row.id;
|
||||
const scoreClass = scoreTone(row.final_score);
|
||||
return (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
className={`scan-table-row ${selected ? "selected" : ""}`}
|
||||
onClick={() => onSelectRow?.(row)}
|
||||
>
|
||||
<div className="scan-rank-cell">
|
||||
<div className={`scan-rank-circle ${scoreClass}`}>
|
||||
{row.rank || index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scan-city-cell">
|
||||
<div className="scan-city-thumb">
|
||||
<div className="scan-city-thumb-fill" />
|
||||
</div>
|
||||
<div className="scan-city-copy">
|
||||
<div className="scan-city-name">{localizedCityName}</div>
|
||||
<div className="scan-city-sub">
|
||||
{row.market_question || row.target_label || "--"}
|
||||
</div>
|
||||
<div className="scan-city-volume">{formatVolume(row.volume)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scan-time-cell">
|
||||
<div className="scan-time-main">{row.local_time || "--"}</div>
|
||||
<div className={`scan-phase-badge ${phaseMeta.tone}`}>
|
||||
{phaseMeta.label}
|
||||
</div>
|
||||
<div className="scan-time-remaining">
|
||||
{formatWindowMinutes(row.remaining_window_minutes, locale)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProbabilityPreview row={row} locale={locale} />
|
||||
|
||||
<div className="scan-trade-cell">
|
||||
<div className={`scan-trade-main ${row.side === "no" ? "sell" : "buy"}`}>
|
||||
{formatAction(row, locale)}
|
||||
</div>
|
||||
<div className="scan-trade-sub">
|
||||
{formatPercent(row.ask != null ? row.ask * 100 : null)} →{" "}
|
||||
{formatPercent(row.model_probability != null ? row.model_probability * 100 : null)}
|
||||
</div>
|
||||
<div className="scan-trade-note">
|
||||
{row.target_label || row.market_direction || "--"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`scan-edge-cell ${Number(row.edge_percent || 0) >= 0 ? "positive" : "negative"}`}>
|
||||
{formatPercent(row.edge_percent, true)}
|
||||
</div>
|
||||
|
||||
<div className="scan-score-cell">
|
||||
<ScoreRing score={row.final_score} />
|
||||
</div>
|
||||
|
||||
<div className="scan-row-fav">
|
||||
<Star size={16} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Crosshair,
|
||||
Clock,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
SlidersHorizontal,
|
||||
Bolt,
|
||||
CircleDot,
|
||||
Clock3,
|
||||
Info,
|
||||
Search,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import type { ScanTerminalFilters } from "@/lib/dashboard-types";
|
||||
@@ -17,38 +17,41 @@ export interface FilterState extends ScanTerminalFilters {}
|
||||
const SCAN_MODES = [
|
||||
{
|
||||
key: "tradable" as const,
|
||||
icon: Crosshair,
|
||||
icon: Bolt,
|
||||
labelEn: "Tradable",
|
||||
labelZh: "可交易机会",
|
||||
descEn: "Markets with immediate trading value",
|
||||
descZh: "交易价值最高的市场",
|
||||
descEn: "Find the best immediate trade",
|
||||
descZh: "发现当前最值得交易的市场",
|
||||
},
|
||||
{
|
||||
key: "early" as const,
|
||||
icon: Clock,
|
||||
icon: Clock3,
|
||||
labelEn: "Early",
|
||||
labelZh: "早期机会",
|
||||
descEn: "Long-horizon positions",
|
||||
descZh: "长周期布局",
|
||||
descEn: "Long-horizon, lower-priced setups",
|
||||
descZh: "长时间布局,低价市场",
|
||||
},
|
||||
{
|
||||
key: "touch" as const,
|
||||
icon: Zap,
|
||||
icon: CircleDot,
|
||||
labelEn: "Touch Play",
|
||||
labelZh: "触达博弈",
|
||||
descEn: "Approaching settle threshold",
|
||||
descZh: "触达博弈最高的市场",
|
||||
descEn: "Markets approaching the settle line",
|
||||
descZh: "接近决策,博弈是否触达",
|
||||
},
|
||||
{
|
||||
key: "trend" as const,
|
||||
icon: TrendingUp,
|
||||
labelEn: "Trend",
|
||||
labelZh: "趋势确认",
|
||||
descEn: "Trend-confirmed opportunities",
|
||||
descZh: "趋势确认、顺势加仓",
|
||||
descEn: "Trend-confirmed follow-through",
|
||||
descZh: "趋势明朗,顺势交易",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const LIQUIDITY_OPTIONS = [500, 1000, 5000, 10000];
|
||||
const EDGE_OPTIONS = [1, 2, 3, 5, 8];
|
||||
|
||||
export function ScanFilterPanel({
|
||||
value,
|
||||
onChange,
|
||||
@@ -75,10 +78,19 @@ export function ScanFilterPanel({
|
||||
|
||||
return (
|
||||
<aside className="scan-filter-panel">
|
||||
{/* === Scan Mode Section === */}
|
||||
<div className="scan-filter-section">
|
||||
<div className="scan-filter-label">
|
||||
{isEn ? "Scan Mode" : "扫描模式"}
|
||||
<div className="scan-sidebar-brand">
|
||||
<div className="scan-sidebar-brand-mark">
|
||||
<Bolt size={22} strokeWidth={2.2} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="scan-sidebar-brand-name">PolyWeather</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="scan-filter-section">
|
||||
<div className="scan-filter-heading">
|
||||
<span>{isEn ? "Scan Mode" : "扫描模式"}</span>
|
||||
<Info size={14} />
|
||||
</div>
|
||||
<div className="scan-mode-tabs">
|
||||
{SCAN_MODES.map((mode) => {
|
||||
@@ -87,91 +99,111 @@ export function ScanFilterPanel({
|
||||
return (
|
||||
<button
|
||||
key={mode.key}
|
||||
type="button"
|
||||
className={`scan-mode-tab ${isActive ? "active" : ""}`}
|
||||
onClick={() => updateFilter("scan_mode", mode.key)}
|
||||
title={isEn ? mode.descEn : mode.descZh}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span className="scan-mode-tab-label">
|
||||
{isEn ? mode.labelEn : mode.labelZh}
|
||||
<span className="scan-mode-icon">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<span className="scan-mode-copy">
|
||||
<span className="scan-mode-tab-label">
|
||||
{isEn ? mode.labelEn : mode.labelZh}
|
||||
</span>
|
||||
<span className="scan-mode-tab-sub">
|
||||
{isEn ? mode.descEn : mode.descZh}
|
||||
</span>
|
||||
</span>
|
||||
{isActive && <span className="scan-mode-tab-indicator" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* === Filter Controls === */}
|
||||
<div className="scan-filter-section">
|
||||
<div className="scan-filter-label">
|
||||
<SlidersHorizontal size={14} />
|
||||
{isEn ? "Filter Criteria" : "筛选条件"}
|
||||
<section className="scan-filter-section">
|
||||
<div className="scan-filter-heading">
|
||||
<span>{isEn ? "Filters" : "筛选条件"}</span>
|
||||
<Info size={14} />
|
||||
</div>
|
||||
|
||||
{/* Price Range */}
|
||||
<div className="scan-filter-row">
|
||||
<span className="scan-filter-row-label">
|
||||
<div className="scan-range-card">
|
||||
<div className="scan-filter-row-title">
|
||||
{isEn ? "Price Range" : "价格范围"}
|
||||
</span>
|
||||
<div className="scan-range-display">
|
||||
<span>{value.min_price.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="scan-range-track-wrap">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={value.min_price * 100}
|
||||
value={Math.round(value.min_price * 100)}
|
||||
onChange={(e) =>
|
||||
updateFilter(
|
||||
"min_price",
|
||||
Math.min(Number(e.target.value) / 100, value.max_price),
|
||||
)
|
||||
}
|
||||
className="scan-range-slider"
|
||||
className="scan-range-slider min"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={value.max_price * 100}
|
||||
value={Math.round(value.max_price * 100)}
|
||||
onChange={(e) =>
|
||||
updateFilter(
|
||||
"max_price",
|
||||
Math.max(Number(e.target.value) / 100, value.min_price),
|
||||
)
|
||||
}
|
||||
className="scan-range-slider"
|
||||
className="scan-range-slider max"
|
||||
/>
|
||||
</div>
|
||||
<div className="scan-range-labels">
|
||||
<span>{value.min_price.toFixed(2)}</span>
|
||||
<span>{value.max_price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Min Edge */}
|
||||
<div className="scan-filter-row">
|
||||
<label className="scan-filter-row">
|
||||
<span className="scan-filter-row-label">
|
||||
{isEn ? "Min Liquidity" : "最小成交量"}
|
||||
</span>
|
||||
<select
|
||||
className="scan-select"
|
||||
value={value.min_liquidity}
|
||||
onChange={(e) => updateFilter("min_liquidity", Number(e.target.value))}
|
||||
>
|
||||
{LIQUIDITY_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
${option.toLocaleString("en-US")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="scan-filter-row">
|
||||
<span className="scan-filter-row-label">
|
||||
{isEn ? "Min Edge" : "最小边际优势"}
|
||||
</span>
|
||||
<div className="scan-range-display">
|
||||
<span>{value.min_edge_pct}%</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={20}
|
||||
value={value.min_edge_pct}
|
||||
onChange={(e) =>
|
||||
updateFilter("min_edge_pct", Number(e.target.value))
|
||||
}
|
||||
className="scan-range-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
className="scan-select"
|
||||
value={value.min_edge_pct}
|
||||
onChange={(e) => updateFilter("min_edge_pct", Number(e.target.value))}
|
||||
>
|
||||
{EDGE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}%
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* High Liquidity Only */}
|
||||
<div className="scan-filter-row">
|
||||
<div className="scan-filter-row inline">
|
||||
<span className="scan-filter-row-label">
|
||||
{isEn ? "High Liquidity Only" : "只看高流动性"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`scan-toggle ${value.high_liquidity_only ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
const nextValue = !value.high_liquidity_only;
|
||||
@@ -180,16 +212,16 @@ export function ScanFilterPanel({
|
||||
high_liquidity_only: nextValue,
|
||||
min_liquidity: nextValue
|
||||
? Math.max(value.min_liquidity, 5000)
|
||||
: 500,
|
||||
: value.min_liquidity,
|
||||
});
|
||||
}}
|
||||
aria-pressed={value.high_liquidity_only}
|
||||
>
|
||||
<span className="scan-toggle-knob" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Market Type */}
|
||||
<div className="scan-filter-row">
|
||||
<label className="scan-filter-row">
|
||||
<span className="scan-filter-row-label">
|
||||
{isEn ? "Market Type" : "市场类型"}
|
||||
</span>
|
||||
@@ -208,12 +240,11 @@ export function ScanFilterPanel({
|
||||
</option>
|
||||
<option value="all">{isEn ? "All Markets" : "所有市场"}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Time Range */}
|
||||
<div className="scan-filter-row">
|
||||
<label className="scan-filter-row">
|
||||
<span className="scan-filter-row-label">
|
||||
{isEn ? "Time Range" : "时间周期"}
|
||||
{isEn ? "Time Range" : "时间范围"}
|
||||
</span>
|
||||
<select
|
||||
className="scan-select"
|
||||
@@ -229,11 +260,11 @@ export function ScanFilterPanel({
|
||||
<option value="tomorrow">{isEn ? "Tomorrow" : "明天"}</option>
|
||||
<option value="week">{isEn ? "This Week" : "本周"}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{/* === CTA === */}
|
||||
<button
|
||||
type="button"
|
||||
className="scan-cta-button"
|
||||
onClick={() => onScan?.(value)}
|
||||
disabled={isScanning}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { TrendingUp, BarChart3, Radio, DollarSign, Target } from "lucide-react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import type { ScanTerminalResponse } from "@/lib/dashboard-types";
|
||||
|
||||
@@ -17,68 +16,54 @@ export function ScanKPIBar({ data }: { data: KPIData }) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
const kpis = [
|
||||
const cards = [
|
||||
{
|
||||
icon: Target,
|
||||
label: isEn ? "Recommended" : "推荐机会",
|
||||
value: String(data.recommended_count),
|
||||
delta: `${isEn ? "Showing" : "当前展示"} ${data.visible_count}`,
|
||||
accent: "green" as const,
|
||||
note: `${isEn ? "Visible" : "当前展示"} ${data.visible_count}`,
|
||||
tone: "green",
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
label: isEn ? "Avg Edge" : "平均边际优势",
|
||||
value:
|
||||
data.avg_edge_percent != null
|
||||
? `+${data.avg_edge_percent.toFixed(1)}%`
|
||||
: "--",
|
||||
delta: `${isEn ? "Candidates" : "候选总数"} ${data.candidate_total}`,
|
||||
accent: "green" as const,
|
||||
note: `${isEn ? "Candidates" : "候选总数"} ${data.candidate_total}`,
|
||||
tone: "purple",
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
label: isEn ? "Avg Confidence" : "平均主信号置信度",
|
||||
value:
|
||||
data.avg_primary_confidence != null
|
||||
? `${data.avg_primary_confidence.toFixed(0)}`
|
||||
? `+${data.avg_primary_confidence.toFixed(1)}%`
|
||||
: "--",
|
||||
delta: isEn ? "Main signal score" : "主信号评分",
|
||||
accent: "green" as const,
|
||||
note: isEn ? "Main signal score" : "主信号评分",
|
||||
tone: "blue",
|
||||
},
|
||||
{
|
||||
icon: Radio,
|
||||
label: isEn ? "Tradable Markets" : "可交易市场",
|
||||
value: String(data.tradable_market_count),
|
||||
delta: data.resolved_market_type || "maxtemp",
|
||||
accent: "purple" as const,
|
||||
note: `${isEn ? "Filtered" : "过滤后"} / ${data.candidate_total || 0}`,
|
||||
tone: "orange",
|
||||
},
|
||||
{
|
||||
icon: DollarSign,
|
||||
label: isEn ? "Total Volume" : "总成交量",
|
||||
value: formatVolume(data.total_volume),
|
||||
delta: isEn ? "Past 24h" : "过去 24 小时",
|
||||
accent: "purple" as const,
|
||||
note: isEn ? "Past 24 hours" : "过去 24 小时",
|
||||
tone: "neutral",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="scan-kpi-bar">
|
||||
{kpis.map((kpi) => {
|
||||
const Icon = kpi.icon;
|
||||
return (
|
||||
<div
|
||||
key={kpi.label}
|
||||
className={`scan-kpi-card scan-kpi-${kpi.accent}`}
|
||||
>
|
||||
<div className="scan-kpi-header">
|
||||
<Icon size={14} className="scan-kpi-icon" />
|
||||
<span className="scan-kpi-label">{kpi.label}</span>
|
||||
</div>
|
||||
<div className="scan-kpi-value">{kpi.value}</div>
|
||||
{kpi.delta && <div className="scan-kpi-delta">{kpi.delta}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<section className="scan-kpi-bar">
|
||||
{cards.map((card) => (
|
||||
<article key={card.label} className={`scan-kpi-card ${card.tone}`}>
|
||||
<div className="scan-kpi-label">{card.label}</div>
|
||||
<div className="scan-kpi-value">{card.value}</div>
|
||||
<div className="scan-kpi-note">{card.note}</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
Bell,
|
||||
Menu,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
startTransition,
|
||||
useDeferredValue,
|
||||
@@ -11,14 +17,13 @@ import {
|
||||
import styles from "./Dashboard.module.css";
|
||||
import detailChromeStyles from "./DetailPanelChrome.module.css";
|
||||
import modalChromeStyles from "./ModalChrome.module.css";
|
||||
import { HeaderBar } from "@/components/dashboard/HeaderBar";
|
||||
import {
|
||||
FilterState,
|
||||
ScanFilterPanel,
|
||||
} from "@/components/dashboard/ScanFilterPanel";
|
||||
import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar";
|
||||
import { OpportunityTable } from "@/components/dashboard/OpportunityTable";
|
||||
import { DashboardStoreProvider, useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { DashboardStoreProvider } from "@/hooks/useDashboardStore";
|
||||
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||
import { dashboardClient } from "@/lib/dashboard-client";
|
||||
import type {
|
||||
@@ -37,18 +42,19 @@ const DEFAULT_FILTERS: FilterState = {
|
||||
min_price: 0.05,
|
||||
max_price: 0.95,
|
||||
min_edge_pct: 2,
|
||||
min_liquidity: 500,
|
||||
min_liquidity: 1000,
|
||||
high_liquidity_only: false,
|
||||
market_type: "maxtemp",
|
||||
time_range: "today",
|
||||
limit: 25,
|
||||
limit: 28,
|
||||
};
|
||||
|
||||
const NAV_ITEMS = ["扫描台", "市场", "分析", "组合", "监控", "设置"];
|
||||
|
||||
function formatPercent(value?: number | null, signed = false) {
|
||||
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||
const numeric = Number(value);
|
||||
if (!signed) return `${numeric.toFixed(1)}%`;
|
||||
return `${numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
|
||||
return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatProbability(value?: number | null) {
|
||||
@@ -69,28 +75,36 @@ function formatVolume(value?: number | null) {
|
||||
return `$${numeric.toFixed(0)}`;
|
||||
}
|
||||
|
||||
function formatMinutes(value?: number | null, locale = "zh-CN") {
|
||||
function formatRemainingWindow(value?: number | null, locale = "zh-CN") {
|
||||
if (value == null || !Number.isFinite(Number(value))) return "--";
|
||||
const numeric = Math.max(0, Math.round(Number(value)));
|
||||
if (locale === "en-US") return `${numeric}m`;
|
||||
return `${numeric} 分钟`;
|
||||
const hours = Math.floor(numeric / 60);
|
||||
const minutes = numeric % 60;
|
||||
if (locale === "en-US") {
|
||||
if (hours <= 0) return `${minutes}m`;
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
if (hours <= 0) return `${minutes} 分钟`;
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
function scoreTone(score?: number | null) {
|
||||
const numeric = Number(score || 0);
|
||||
if (numeric >= 85) return "green";
|
||||
if (numeric >= 70) return "amber";
|
||||
if (numeric >= 70) return "yellow";
|
||||
return "red";
|
||||
}
|
||||
|
||||
function confidenceDotCount(score?: number | null) {
|
||||
function confidenceLabel(score?: number | null, locale = "zh-CN") {
|
||||
const numeric = Number(score || 0);
|
||||
if (numeric >= 90) return 5;
|
||||
if (numeric >= 80) return 4;
|
||||
if (numeric >= 70) return 3;
|
||||
if (numeric >= 60) return 2;
|
||||
if (numeric > 0) return 1;
|
||||
return 0;
|
||||
if (locale === "en-US") {
|
||||
if (numeric >= 85) return "High";
|
||||
if (numeric >= 70) return "Medium";
|
||||
return "Watch";
|
||||
}
|
||||
if (numeric >= 85) return "高";
|
||||
if (numeric >= 70) return "中";
|
||||
return "观察";
|
||||
}
|
||||
|
||||
function getSideRow(
|
||||
@@ -98,7 +112,7 @@ function getSideRow(
|
||||
selectedRow: ScanOpportunityRow,
|
||||
side: "yes" | "no",
|
||||
) {
|
||||
const rows = Array.isArray(marketScan?.scan_rows) ? marketScan?.scan_rows : [];
|
||||
const rows = Array.isArray(marketScan?.scan_rows) ? marketScan.scan_rows : [];
|
||||
const matched = rows.find(
|
||||
(row) =>
|
||||
row.market_slug === selectedRow.market_slug &&
|
||||
@@ -110,6 +124,36 @@ function getSideRow(
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildComparisonBuckets(
|
||||
marketScan: MarketScan | null | undefined,
|
||||
row: ScanOpportunityRow | null,
|
||||
) {
|
||||
const buckets = Array.isArray(marketScan?.top_buckets)
|
||||
? marketScan?.top_buckets
|
||||
: Array.isArray(marketScan?.all_buckets)
|
||||
? marketScan?.all_buckets?.slice(0, 6)
|
||||
: [];
|
||||
if (buckets.length) {
|
||||
return buckets
|
||||
.slice(0, 6)
|
||||
.map((bucket) => ({
|
||||
label: String(bucket.temp ?? bucket.value ?? bucket.label ?? "--"),
|
||||
model: Number(bucket.probability ?? 0) * 100,
|
||||
market: Number(bucket.market_price ?? bucket.yes_buy ?? 0) * 100,
|
||||
}))
|
||||
.filter((bucket) => bucket.label !== "--");
|
||||
}
|
||||
|
||||
if (!row) return [];
|
||||
return [
|
||||
{
|
||||
label: row.target_label || "--",
|
||||
model: Number(row.model_event_probability || 0) * 100,
|
||||
market: Number(row.market_event_probability || 0) * 100,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function DetailPanel({
|
||||
row,
|
||||
marketScan,
|
||||
@@ -125,8 +169,13 @@ function DetailPanel({
|
||||
if (!row) {
|
||||
return (
|
||||
<aside className="scan-detail-panel">
|
||||
<div className="scan-detail-empty">
|
||||
{isEn ? "Select a row to inspect the main signal." : "选择一条机会,查看主信号详情。"}
|
||||
<div className="scan-empty-state">
|
||||
<div className="scan-empty-title">
|
||||
{isEn ? "Pick a market row" : "选择一条机会"}
|
||||
</div>
|
||||
<div className="scan-empty-copy">
|
||||
{isEn ? "The right rail will show the main signal details." : "右侧会展示主信号详情。"}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
@@ -137,195 +186,217 @@ function DetailPanel({
|
||||
row.city_display_name || row.display_name || row.city,
|
||||
locale,
|
||||
);
|
||||
const localizedAirport = getLocalizedAirportName(
|
||||
row.city,
|
||||
row.airport || "",
|
||||
locale,
|
||||
);
|
||||
const localizedAirport = getLocalizedAirportName(row.city, row.airport || "", locale);
|
||||
const detailSignal = marketScan?.primary_signal as PrimarySignal | null | undefined;
|
||||
const displayRow = detailSignal && detailSignal.market_slug === row.market_slug ? detailSignal : row;
|
||||
const distributionBias = marketScan?.distribution_bias || row.distribution_bias || null;
|
||||
const displayRow =
|
||||
detailSignal && detailSignal.market_slug === row.market_slug
|
||||
? detailSignal
|
||||
: row;
|
||||
const yesRow = getSideRow(marketScan, row, "yes");
|
||||
const noRow = getSideRow(marketScan, row, "no");
|
||||
const tone = scoreTone(displayRow.final_score);
|
||||
const filledDots = confidenceDotCount(displayRow.final_score);
|
||||
const comparisonBuckets = buildComparisonBuckets(marketScan, row);
|
||||
const maxBar = Math.max(
|
||||
1,
|
||||
...comparisonBuckets.flatMap((bucket) => [bucket.model, bucket.market]),
|
||||
);
|
||||
const scoreClass = scoreTone(displayRow.final_score);
|
||||
|
||||
return (
|
||||
<aside className="scan-detail-panel">
|
||||
<div className="scan-detail-header">
|
||||
<div className="scan-detail-hero-placeholder" />
|
||||
<div className="scan-detail-city-info">
|
||||
<div className="scan-detail-city-name">{localizedCityName}</div>
|
||||
<div className="scan-detail-city-sub">
|
||||
{displayRow.market_question || displayRow.target_label || "--"}
|
||||
<div className="scan-detail-top">
|
||||
<div className="scan-detail-hero-placeholder" />
|
||||
<div className="scan-detail-title-wrap">
|
||||
<div className="scan-detail-city-name">{localizedCityName}</div>
|
||||
<div className="scan-detail-city-sub">
|
||||
{displayRow.market_question || displayRow.target_label || "--"}
|
||||
</div>
|
||||
<div className="scan-phase-badge red">
|
||||
{displayRow.window_phase || (isEn ? "Main Signal" : "主信号")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-detail-volume">
|
||||
{formatVolume(displayRow.volume)}{" "}
|
||||
</div>
|
||||
<button type="button" className="scan-detail-icon-button" aria-label="close">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="scan-detail-volume-row">
|
||||
<div>
|
||||
<div className="scan-detail-volume-big">{formatVolume(displayRow.volume)}</div>
|
||||
<div className="scan-detail-volume-caption">
|
||||
{isEn ? "24h volume" : "24h 成交量"}
|
||||
{loading ? ` · ${isEn ? "loading" : "载入中"}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="scan-detail-action-button">
|
||||
{isEn ? "Add Watch" : "添加自选"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="scan-detail-section">
|
||||
<section className="scan-detail-section">
|
||||
<div className="scan-detail-section-title">
|
||||
{isEn ? "Current Context" : "当前概况"}
|
||||
</div>
|
||||
<div className="scan-conditions-table">
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{isEn ? "Local Time" : "当地时间"}
|
||||
</span>
|
||||
<span className="scan-condition-value">{row.local_time || "--"}</span>
|
||||
<div className="scan-kv-list">
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Local Time" : "当前时间"}</span>
|
||||
<strong>{row.local_time || "--"}</strong>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{isEn ? "Current Temp" : "当前温度"}
|
||||
</span>
|
||||
<span className="scan-condition-value">
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Current Temp" : "当前温度"}</span>
|
||||
<strong>
|
||||
{row.current_temp != null ? `${row.current_temp}${row.temp_symbol || ""}` : "--"}
|
||||
</span>
|
||||
</strong>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{isEn ? "Day High (So Far)" : "今日高点(至今)"}
|
||||
</span>
|
||||
<span className="scan-condition-value">
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Day High (So Far)" : "今日最高(至今)"}</span>
|
||||
<strong>
|
||||
{row.current_max_so_far != null
|
||||
? `${row.current_max_so_far}${row.temp_symbol || ""}`
|
||||
: "--"}
|
||||
</span>
|
||||
</strong>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{isEn ? "Target" : "目标温度"}
|
||||
</span>
|
||||
<span className="scan-condition-value">
|
||||
{displayRow.target_label || "--"}
|
||||
</span>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Target" : "目标温度"}</span>
|
||||
<strong>{displayRow.target_label || "--"}</strong>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{isEn ? "Gap To Target" : "距目标"}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"scan-condition-value",
|
||||
Number(displayRow.gap_to_target || 0) <= 0
|
||||
? "accent-green"
|
||||
: "accent-red",
|
||||
)}
|
||||
>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Gap To Target" : "距离目标"}</span>
|
||||
<strong className={Number(displayRow.gap_to_target || 0) <= 0 ? "warn" : "danger"}>
|
||||
{displayRow.gap_to_target != null
|
||||
? `${displayRow.gap_to_target >= 0 ? "+" : ""}${displayRow.gap_to_target.toFixed(1)}${row.temp_symbol || ""}`
|
||||
: "--"}
|
||||
</span>
|
||||
</strong>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{isEn ? "Window Left" : "剩余有效时间"}
|
||||
</span>
|
||||
<span className="scan-condition-value">
|
||||
{formatMinutes(displayRow.remaining_window_minutes, locale)}
|
||||
</span>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Window Left" : "剩余有效时间"}</span>
|
||||
<strong>{formatRemainingWindow(displayRow.remaining_window_minutes, locale)}</strong>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{isEn ? "Bias" : "分布偏移"}
|
||||
</span>
|
||||
<span className="scan-condition-value">
|
||||
{distributionBias?.direction || "--"} ·{" "}
|
||||
{distributionBias?.score != null
|
||||
? distributionBias.score.toFixed(0)
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Bias" : "分布偏移"}</span>
|
||||
<strong>
|
||||
{displayRow.distribution_bias_direction || "--"} ·{" "}
|
||||
{displayRow.distribution_bias_score != null
|
||||
? displayRow.distribution_bias_score.toFixed(0)
|
||||
: "--"}
|
||||
</span>
|
||||
</strong>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{isEn ? "Airport" : "机场锚点"}
|
||||
</span>
|
||||
<span className="scan-condition-value">{localizedAirport || "--"}</span>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Airport" : "机场锚点"}</span>
|
||||
<strong>{localizedAirport || "--"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="scan-detail-section">
|
||||
<section className="scan-detail-section">
|
||||
<div className="scan-timeline-head">
|
||||
<span>00:00</span>
|
||||
<span>23:59</span>
|
||||
</div>
|
||||
<div className="scan-timeline-bar">
|
||||
<span
|
||||
className="scan-timeline-knob"
|
||||
style={{
|
||||
left: `${Math.max(
|
||||
8,
|
||||
Math.min(
|
||||
92,
|
||||
100 - Math.min(100, Number(displayRow.remaining_window_minutes || 0) / 9),
|
||||
),
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="scan-timeline-caption">
|
||||
{displayRow.window_phase || (isEn ? "Window phase" : "窗口阶段")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="scan-detail-section">
|
||||
<div className="scan-detail-section-title">
|
||||
{isEn ? "Recommended Trade" : "推荐交易"}
|
||||
{isEn ? "Probability Comparison" : "概率分布对比"}
|
||||
</div>
|
||||
<div className="scan-trade-cards">
|
||||
<div className="scan-trade-card yes">
|
||||
<div className="scan-trade-card-title">BUY YES</div>
|
||||
<div className="scan-trade-card-price">
|
||||
{formatPrice(yesRow?.ask ?? marketScan?.yes_buy)} / {formatProbability(yesRow?.model_probability)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"scan-trade-card-edge",
|
||||
Number(yesRow?.edge_percent || 0) >= 0 ? "positive" : "negative",
|
||||
)}
|
||||
>
|
||||
{formatPercent(yesRow?.edge_percent, true)}
|
||||
</div>
|
||||
<div className="scan-trade-card-note">
|
||||
{isEn ? "Spread" : "点差"} {formatPrice(yesRow?.spread)} ·{" "}
|
||||
{isEn ? "Liquidity" : "流动性"} {formatVolume(yesRow?.book_liquidity ?? yesRow?.market_liquidity)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-trade-card no">
|
||||
<div className="scan-trade-card-title">BUY NO</div>
|
||||
<div className="scan-trade-card-price">
|
||||
{formatPrice(noRow?.ask ?? marketScan?.no_buy)} / {formatProbability(noRow?.model_probability)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"scan-trade-card-edge",
|
||||
Number(noRow?.edge_percent || 0) >= 0 ? "positive" : "negative",
|
||||
)}
|
||||
>
|
||||
{formatPercent(noRow?.edge_percent, true)}
|
||||
</div>
|
||||
<div className="scan-trade-card-note">
|
||||
{isEn ? "Spread" : "点差"} {formatPrice(noRow?.spread)} ·{" "}
|
||||
{isEn ? "Liquidity" : "流动性"} {formatVolume(noRow?.book_liquidity ?? noRow?.market_liquidity)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-chart-legend">
|
||||
<span>
|
||||
<i className="dot green" />
|
||||
{isEn ? "Model" : "模型预测"}
|
||||
</span>
|
||||
<span>
|
||||
<i className="dot blue" />
|
||||
{isEn ? "Market" : "市场隐含"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scan-detail-score">
|
||||
<div>
|
||||
<div className="scan-detail-score-big">
|
||||
{Number(displayRow.final_score || 0).toFixed(0)}
|
||||
<span className="scan-detail-score-suffix">/100</span>
|
||||
</div>
|
||||
<div className="scan-detail-score-label">
|
||||
{isEn ? "Composite signal score" : "综合主信号评分"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-confidence-dots">
|
||||
{Array.from({ length: 5 }).map((_, index) => {
|
||||
const filled = index < filledDots;
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className={clsx(
|
||||
"scan-confidence-dot",
|
||||
filled && "filled",
|
||||
filled && tone === "amber" && "amber",
|
||||
filled && tone === "red" && "red",
|
||||
)}
|
||||
<div className="scan-chart-bars">
|
||||
{comparisonBuckets.map((bucket) => (
|
||||
<div key={bucket.label} className="scan-chart-group">
|
||||
<div
|
||||
className="scan-chart-col model"
|
||||
style={{ height: `${Math.max(8, (bucket.model / maxBar) * 120)}px` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className="scan-chart-col market"
|
||||
style={{ height: `${Math.max(8, (bucket.market / maxBar) * 120)}px` }}
|
||||
/>
|
||||
<div className="scan-chart-label">{bucket.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</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"} {displayRow.target_label || ""}
|
||||
</div>
|
||||
<p>
|
||||
{formatPrice(yesRow?.ask ?? marketScan?.yes_buy)} →{" "}
|
||||
{formatProbability(yesRow?.model_probability)}
|
||||
</p>
|
||||
<p className="positive">{formatPercent(yesRow?.edge_percent, true)} {isEn ? "edge" : "边际优势"}</p>
|
||||
<p>{isEn ? "Spread" : "点差"} {formatPrice(yesRow?.spread)}</p>
|
||||
</div>
|
||||
<div className="scan-trade-card sell">
|
||||
<div className="scan-trade-card-title">
|
||||
{isEn ? "Buy No" : "买入 No"} {displayRow.target_label || ""}
|
||||
</div>
|
||||
<p>
|
||||
{formatPrice(noRow?.ask ?? marketScan?.no_buy)} →{" "}
|
||||
{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 ? "Spread" : "点差"} {formatPrice(noRow?.spread)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="scan-detail-score-block">
|
||||
<div className="scan-detail-score-head">
|
||||
<div>
|
||||
<div className="scan-detail-score-label-text">
|
||||
{isEn ? "Composite Score" : "综合得分"}
|
||||
</div>
|
||||
<div className="scan-detail-score-meta">
|
||||
{isEn ? "Confidence" : "置信度"}: {confidenceLabel(displayRow.final_score, locale)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`scan-detail-score-value ${scoreClass}`}>
|
||||
{Number(displayRow.final_score || 0).toFixed(0)}
|
||||
<span>/100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-detail-score-line">
|
||||
<span style={{ width: `${Math.max(0, Math.min(100, Number(displayRow.final_score || 0)))}%` }} />
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ScanTerminalScreen() {
|
||||
const store = useDashboardStore();
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
const [draftFilters, setDraftFilters] = useState<FilterState>(DEFAULT_FILTERS);
|
||||
@@ -336,15 +407,11 @@ 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 deferredRows = useDeferredValue(terminalData?.rows || []);
|
||||
|
||||
const selectedRow = useMemo(() => {
|
||||
if (!deferredRows.length) return null;
|
||||
return (
|
||||
deferredRows.find((row) => row.id === selectedRowId) ||
|
||||
deferredRows[0] ||
|
||||
null
|
||||
);
|
||||
return deferredRows.find((row) => row.id === selectedRowId) || deferredRows[0] || null;
|
||||
}, [deferredRows, selectedRowId]);
|
||||
|
||||
const fetchTerminal = async (filters: FilterState, force = false) => {
|
||||
@@ -401,9 +468,7 @@ function ScanTerminalScreen() {
|
||||
const intervalId = window.setInterval(() => {
|
||||
void fetchTerminal(activeFilters, false);
|
||||
}, 30_000);
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [activeFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -414,18 +479,7 @@ function ScanTerminalScreen() {
|
||||
const selectedDetail = selectedRow ? detailByRowId[selectedRow.id] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.root,
|
||||
detailChromeStyles.root,
|
||||
modalChromeStyles.root,
|
||||
)}
|
||||
>
|
||||
<HeaderBar
|
||||
refreshAction={() => fetchTerminal(activeFilters, true)}
|
||||
refreshSpinning={loading || store.loadingState.refresh}
|
||||
/>
|
||||
|
||||
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root)}>
|
||||
<div className="scan-terminal">
|
||||
<ScanFilterPanel
|
||||
value={draftFilters}
|
||||
@@ -438,27 +492,42 @@ function ScanTerminalScreen() {
|
||||
/>
|
||||
|
||||
<main className="scan-data-grid">
|
||||
<div className="scan-data-grid-header">
|
||||
<div>
|
||||
<div className="scan-data-grid-title">
|
||||
{isEn ? "Tradable Opportunities" : "可交易机会"}
|
||||
</div>
|
||||
<div className="scan-data-grid-subtitle">
|
||||
{isEn
|
||||
? "REST-only EMOS scan with one main signal per city/date."
|
||||
: "基于 EMOS 分布与 CLOB REST 盘口,只输出每个城市/日期的单一主信号。"}
|
||||
</div>
|
||||
<div className="scan-topbar">
|
||||
<div className="scan-topbar-tabs">
|
||||
{NAV_ITEMS.map((item, index) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={`scan-topbar-tab ${index === 0 ? "active" : ""}`}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="scan-data-grid-controls">
|
||||
<span className="scan-status-badge tone-neutral">
|
||||
{isEn ? "Updated" : "数据时间"} ·{" "}
|
||||
{terminalData?.generated_at
|
||||
? terminalData.generated_at.replace("T", " ").slice(0, 19)
|
||||
: "--"}
|
||||
<div className="scan-topbar-actions">
|
||||
<span className="scan-topbar-time">
|
||||
{selectedRow?.local_time || terminalData?.generated_at?.replace("T", " ").slice(11, 19) || "--"}
|
||||
</span>
|
||||
<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" : "自定义提醒"}
|
||||
</button>
|
||||
</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 || {
|
||||
@@ -474,23 +543,51 @@ function ScanTerminalScreen() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="scan-view-tabs">
|
||||
<button type="button" className="scan-view-tab active">
|
||||
{isEn ? "Opportunity List" : "机会列表"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="scan-detail-empty">
|
||||
{isEn ? "Scan failed." : "扫描失败。"} {error}
|
||||
<section className="scan-list-section">
|
||||
<div className="scan-list-header">
|
||||
<div className="scan-list-tabs">
|
||||
<button type="button" className="active">
|
||||
{isEn ? "Opportunity List" : "机会列表"}
|
||||
</button>
|
||||
<button type="button">{isEn ? "Distribution View" : "分布视图"}</button>
|
||||
<button type="button">{isEn ? "Calendar View" : "日历视图"}</button>
|
||||
</div>
|
||||
<div className="scan-list-controls">
|
||||
<div className="scan-sort-pill">
|
||||
{isEn ? "Sort: Score" : "排序:综合得分"}
|
||||
</div>
|
||||
<button type="button" className="scan-icon-pill" aria-label="menu">
|
||||
<Menu size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<OpportunityTable
|
||||
rows={deferredRows}
|
||||
selectedRowId={selectedRowId}
|
||||
onSelectRow={(row) => setSelectedRowId(row.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="scan-empty-state">
|
||||
<div className="scan-empty-title">
|
||||
{isEn ? "Scan failed" : "扫描失败"}
|
||||
</div>
|
||||
<div className="scan-empty-copy">{error}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<OpportunityTable
|
||||
rows={deferredRows}
|
||||
selectedRowId={selectedRowId}
|
||||
onSelectRow={(row) => setSelectedRowId(row.id)}
|
||||
/>
|
||||
{deferredRows.length ? (
|
||||
<div className="scan-view-all-wrap">
|
||||
<button type="button" className="scan-view-all-button">
|
||||
{isEn
|
||||
? `View all ${deferredRows.length} opportunities`
|
||||
: `查看全部 ${deferredRows.length} 个机会`}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<DetailPanel
|
||||
|
||||
Reference in New Issue
Block a user