Implement EMOS scan terminal with REST-only market data
This commit is contained in:
@@ -3,10 +3,10 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
|
||||
|
||||
const PolyWeatherDashboard = dynamic(
|
||||
const ScanTerminalDashboard = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/PolyWeatherDashboard").then(
|
||||
(module) => module.PolyWeatherDashboard,
|
||||
import("@/components/dashboard/ScanTerminalDashboard").then(
|
||||
(module) => module.ScanTerminalDashboard,
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
@@ -15,5 +15,5 @@ const PolyWeatherDashboard = dynamic(
|
||||
);
|
||||
|
||||
export function DashboardEntry() {
|
||||
return <PolyWeatherDashboard />;
|
||||
return <ScanTerminalDashboard />;
|
||||
}
|
||||
|
||||
@@ -1,160 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import type {
|
||||
CityDetail,
|
||||
CityListItem,
|
||||
CitySummary,
|
||||
} from "@/lib/dashboard-types";
|
||||
import { getLocalizedCityDisplay } from "@/lib/dashboard-home-copy";
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||
|
||||
interface OpportunityRow {
|
||||
rank: number;
|
||||
city: CityListItem;
|
||||
summary?: CitySummary | null;
|
||||
detail?: CityDetail | null;
|
||||
score: number;
|
||||
tradable: boolean;
|
||||
}
|
||||
|
||||
function getTodayHighLabel(detail?: CityDetail | null, symbol = "°C"): string {
|
||||
const current = detail?.current;
|
||||
if (current?.temp != null) return `${current.temp.toFixed(1)}${symbol}`;
|
||||
return "--";
|
||||
}
|
||||
|
||||
function getTimeLabel(detail?: CityDetail | null): string {
|
||||
if (!detail?.local_time) return "--";
|
||||
return detail.local_time;
|
||||
}
|
||||
|
||||
function getTimezone(detail?: CityDetail | null): string {
|
||||
if (detail?.utc_offset_seconds != null) {
|
||||
const hours = Math.round(detail.utc_offset_seconds / 3600);
|
||||
return `UTC${hours >= 0 ? "+" : ""}${hours}`;
|
||||
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" };
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getMarketVolume(detail?: CityDetail | null): string {
|
||||
const vol =
|
||||
detail?.market_scan?.volume ?? detail?.market_scan?.primary_market?.volume;
|
||||
if (vol == null) return "--";
|
||||
if (vol >= 1_000_000) return `$${(vol / 1_000_000).toFixed(1)}M`;
|
||||
if (vol >= 1_000) return `$${Math.round(vol / 1_000)}K`;
|
||||
return `$${vol.toFixed(0)}`;
|
||||
}
|
||||
|
||||
function getScanStatus(
|
||||
detail?: CityDetail | null,
|
||||
locale = "zh-CN",
|
||||
): { label: string; tone: string } {
|
||||
const scan = detail?.market_scan;
|
||||
if (!scan?.available)
|
||||
return {
|
||||
label: locale === "en-US" ? "No Market" : "无盘口",
|
||||
tone: "neutral",
|
||||
};
|
||||
const pa = scan?.price_analysis;
|
||||
const bestSide = pa?.best_side;
|
||||
const sideView = bestSide === "no" ? pa?.no : pa?.yes;
|
||||
const edgeVal =
|
||||
sideView?.edge != null
|
||||
? Math.abs(Number(sideView.edge)) > 1
|
||||
? Number(sideView.edge) / 100
|
||||
: Number(sideView.edge)
|
||||
: null;
|
||||
if (edgeVal != null && edgeVal >= 0.05)
|
||||
return {
|
||||
label: locale === "en-US" ? "Touch Play" : "触达博弈",
|
||||
tone: "amber",
|
||||
};
|
||||
if (edgeVal != null && edgeVal >= 0.02)
|
||||
return {
|
||||
label: locale === "en-US" ? "Tradable" : "即时确认",
|
||||
tone: "green",
|
||||
};
|
||||
if (edgeVal != null && edgeVal > 0)
|
||||
if (phase === "tomorrow" || phase === "week_ahead") {
|
||||
return { label: locale === "en-US" ? "Early" : "早期机会", tone: "purple" };
|
||||
return { label: locale === "en-US" ? "Market" : "市场", tone: "neutral" };
|
||||
}
|
||||
|
||||
function getBestAction(detail?: CityDetail | null, locale = "zh-CN"): string {
|
||||
const scan = detail?.market_scan;
|
||||
if (!scan?.available) return "--";
|
||||
const pa = scan?.price_analysis;
|
||||
const side = pa?.best_side;
|
||||
const topBucket = scan?.top_buckets?.[0];
|
||||
const label = topBucket?.label || topBucket?.slug || "";
|
||||
if (side === "yes" && label)
|
||||
return `${locale === "en-US" ? "Buy" : "买入"} Yes ${label}`;
|
||||
if (side === "no" && label)
|
||||
return `${locale === "en-US" ? "Buy" : "买入"} No ${label}`;
|
||||
return "--";
|
||||
}
|
||||
|
||||
function getEdge(detail?: CityDetail | null): number | null {
|
||||
const pa = detail?.market_scan?.price_analysis;
|
||||
const bestSide = pa?.best_side;
|
||||
const sideView = bestSide === "no" ? pa?.no : pa?.yes;
|
||||
const edge = sideView?.edge;
|
||||
if (edge == null) return null;
|
||||
const val = Number(edge);
|
||||
return Math.abs(val) > 1 ? val / 100 : val;
|
||||
}
|
||||
|
||||
function getProbabilityBuckets(
|
||||
detail?: CityDetail | null,
|
||||
): Array<{ label: string; probability: number }> {
|
||||
const view = detail?.probabilities?.distribution || [];
|
||||
if (!Array.isArray(view)) return [];
|
||||
return view.slice(0, 8).map((b) => ({
|
||||
label: String(b.label || b.value || ""),
|
||||
probability: Number(b.probability || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
function MiniProbabilityChart({
|
||||
buckets,
|
||||
}: {
|
||||
buckets: Array<{ label: string; probability: number }>;
|
||||
}) {
|
||||
if (!buckets.length) return <span className="scan-no-data">--</span>;
|
||||
const maxProb = Math.max(...buckets.map((b) => b.probability), 0.01);
|
||||
return (
|
||||
<div className="scan-mini-chart">
|
||||
{buckets.map((bucket, i) => (
|
||||
<div key={i} className="scan-mini-bar-col">
|
||||
<div
|
||||
className="scan-mini-bar"
|
||||
style={{
|
||||
height: `${Math.max((bucket.probability / maxProb) * 100, 4)}%`,
|
||||
}}
|
||||
title={`${bucket.label}: ${(bucket.probability * 100).toFixed(1)}%`}
|
||||
/>
|
||||
<span className="scan-mini-bar-label">
|
||||
{bucket.label.replace(/[°CF]/g, "")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDisplayScore(rawScore: number): number {
|
||||
if (rawScore <= 0) return Math.max(0, 70 + rawScore / 1000);
|
||||
if (rawScore > 1000) {
|
||||
return Math.min(100, 80 + (rawScore - 1000) / 30);
|
||||
}
|
||||
return Math.min(80, Math.max(0, (rawScore / 1000) * 80));
|
||||
if (phase === "post_peak") {
|
||||
return { label: locale === "en-US" ? "Post Peak" : "峰后确认", tone: "amber" };
|
||||
}
|
||||
return { label: locale === "en-US" ? "Watching" : "观察中", tone: "neutral" };
|
||||
}
|
||||
|
||||
function ScoreRing({ score }: { score: number }) {
|
||||
const displayScore = getDisplayScore(score);
|
||||
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)}%`;
|
||||
}
|
||||
|
||||
function formatTimeBlock(row: ScanOpportunityRow, locale: string) {
|
||||
const parts: string[] = [];
|
||||
if (row.local_time) {
|
||||
parts.push(row.local_time);
|
||||
}
|
||||
if (row.selected_date) {
|
||||
parts.push(row.selected_date);
|
||||
}
|
||||
return parts.join(" · ") || (locale === "en-US" ? "No time" : "暂无时间");
|
||||
}
|
||||
|
||||
function formatAction(row: ScanOpportunityRow, locale: string) {
|
||||
if (row.side === "yes") {
|
||||
return `${locale === "en-US" ? "Buy" : "买入"} Yes ${row.target_label || ""}`.trim();
|
||||
}
|
||||
if (row.side === "no") {
|
||||
return `${locale === "en-US" ? "Buy" : "买入"} No ${row.target_label || ""}`.trim();
|
||||
}
|
||||
return row.action || "--";
|
||||
}
|
||||
|
||||
function formatProbability(value?: number | null) {
|
||||
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||
return `${(Number(value) * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -191,27 +94,33 @@ function ScoreRing({ score }: { score: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function OpportunityTable({ rows }: { rows: OpportunityRow[] }) {
|
||||
export function OpportunityTable({
|
||||
rows,
|
||||
selectedRowId,
|
||||
onSelectRow,
|
||||
}: {
|
||||
rows: ScanOpportunityRow[];
|
||||
selectedRowId?: string | null;
|
||||
onSelectRow?: (row: ScanOpportunityRow) => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const store = useDashboardStore();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
return (
|
||||
<div className="scan-table-container">
|
||||
{/* Header */}
|
||||
<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 ? "Local Time / Status" : "当前时间 / 阶段"}
|
||||
{isEn ? "Time / Phase" : "时间 / 阶段"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-prob">
|
||||
{isEn ? "Prob. Dist. vs Market" : "模型分布 vs 市场分布"}
|
||||
{isEn ? "EMOS vs Market" : "EMOS vs 市场"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-action">
|
||||
{isEn ? "Best Action" : "最佳机会"}
|
||||
{isEn ? "Best Action" : "最佳动作"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-edge">
|
||||
{isEn ? "Edge" : "边际优势"}
|
||||
@@ -222,90 +131,99 @@ export function OpportunityTable({ rows }: { rows: OpportunityRow[] }) {
|
||||
<span className="scan-th scan-th-fav" />
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{rows.map((row) => {
|
||||
const cityName = getLocalizedCityDisplay(
|
||||
{rows.map((row, index) => {
|
||||
const status = getStatusMeta(row, locale);
|
||||
const localizedCityName = getLocalizedCityName(
|
||||
row.city,
|
||||
row.city_display_name || row.display_name || row.city,
|
||||
locale,
|
||||
row.summary,
|
||||
row.detail,
|
||||
);
|
||||
const status = getScanStatus(row.detail, locale);
|
||||
const edge = getEdge(row.detail);
|
||||
const buckets = getProbabilityBuckets(row.detail);
|
||||
const isSelected = store.selectedCity === row.city.name;
|
||||
const symbol = row.detail?.temp_symbol || "°C";
|
||||
const selected = selectedRowId === row.id;
|
||||
const finalScore = Number(row.final_score || 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.city.name}
|
||||
className={`scan-table-row ${isSelected ? "selected" : ""} ${row.tradable ? "tradable" : ""}`}
|
||||
onClick={() => store.focusCity(row.city.name)}
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
className={`scan-table-row ${selected ? "selected" : ""} ${row.tradable ? "tradable" : ""}`}
|
||||
onClick={() => onSelectRow?.(row)}
|
||||
>
|
||||
{/* Rank */}
|
||||
<span className={`scan-td scan-td-rank rank-${status.tone}`}>
|
||||
<span className="scan-rank-circle">{row.rank}</span>
|
||||
<span className="scan-rank-circle">{row.rank || index + 1}</span>
|
||||
</span>
|
||||
|
||||
{/* City */}
|
||||
<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">{cityName}</span>
|
||||
<span className="scan-city-name">{localizedCityName}</span>
|
||||
<span className="scan-city-sub">
|
||||
{isEn ? "Today's high" : "今日最高温"} ·{" "}
|
||||
{getMarketVolume(row.detail)}
|
||||
{row.target_label || row.market_question || "--"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Time + Status */}
|
||||
<span className="scan-td scan-td-time">
|
||||
<span className="scan-time-text">
|
||||
{getTimeLabel(row.detail)} ({getTimezone(row.detail)})
|
||||
</span>
|
||||
<span className="scan-time-text">{formatTimeBlock(row, locale)}</span>
|
||||
<span className={`scan-status-badge tone-${status.tone}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Probability Chart */}
|
||||
<span className="scan-td scan-td-prob">
|
||||
<MiniProbabilityChart buckets={buckets} />
|
||||
<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>
|
||||
|
||||
{/* Best Action */}
|
||||
<span className="scan-td scan-td-action">
|
||||
<span className="scan-action-text">
|
||||
{getBestAction(row.detail, locale)}
|
||||
{formatAction(row, locale)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Edge */}
|
||||
<span className="scan-td scan-td-edge">
|
||||
<span
|
||||
className={`scan-edge-value ${edge != null && edge > 0 ? "positive" : "neutral"}`}
|
||||
className={`scan-edge-value ${finalScore > 0 ? "positive" : "neutral"}`}
|
||||
>
|
||||
{edge != null
|
||||
? `${edge > 0 ? "+" : ""}${(edge * 100).toFixed(1)}%`
|
||||
: "--"}
|
||||
{formatPercent(row.edge_percent, true)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Score */}
|
||||
<span className="scan-td scan-td-score">
|
||||
<ScoreRing score={row.score} />
|
||||
<ScoreRing score={row.final_score} />
|
||||
</span>
|
||||
|
||||
{/* Favorite */}
|
||||
<span className="scan-td scan-td-fav">
|
||||
<Star size={16} className="scan-fav-icon" />
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import {
|
||||
Crosshair,
|
||||
Clock,
|
||||
@@ -10,16 +10,9 @@ import {
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import styles from "./Dashboard.module.css";
|
||||
import type { ScanTerminalFilters } from "@/lib/dashboard-types";
|
||||
|
||||
interface FilterState {
|
||||
mode: "tradable" | "early" | "touch" | "trend";
|
||||
priceRange: [number, number];
|
||||
minEdge: number;
|
||||
highLiquidityOnly: boolean;
|
||||
marketType: "maxtemp" | "all";
|
||||
timeRange: "today" | "tomorrow" | "week";
|
||||
}
|
||||
export interface FilterState extends ScanTerminalFilters {}
|
||||
|
||||
const SCAN_MODES = [
|
||||
{
|
||||
@@ -57,29 +50,27 @@ const SCAN_MODES = [
|
||||
] as const;
|
||||
|
||||
export function ScanFilterPanel({
|
||||
value,
|
||||
onChange,
|
||||
onScan,
|
||||
isScanning,
|
||||
}: {
|
||||
value: FilterState;
|
||||
onChange?: (filters: FilterState) => void;
|
||||
onScan?: (filters: FilterState) => void;
|
||||
isScanning?: boolean;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
mode: "tradable",
|
||||
priceRange: [0.05, 0.95],
|
||||
minEdge: 2,
|
||||
highLiquidityOnly: false,
|
||||
marketType: "maxtemp",
|
||||
timeRange: "today",
|
||||
});
|
||||
|
||||
const updateFilter = <K extends keyof FilterState>(
|
||||
key: K,
|
||||
value: FilterState[K],
|
||||
nextValue: FilterState[K],
|
||||
) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
onChange?.({
|
||||
...value,
|
||||
[key]: nextValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -92,12 +83,12 @@ export function ScanFilterPanel({
|
||||
<div className="scan-mode-tabs">
|
||||
{SCAN_MODES.map((mode) => {
|
||||
const Icon = mode.icon;
|
||||
const isActive = filters.mode === mode.key;
|
||||
const isActive = value.scan_mode === mode.key;
|
||||
return (
|
||||
<button
|
||||
key={mode.key}
|
||||
className={`scan-mode-tab ${isActive ? "active" : ""}`}
|
||||
onClick={() => updateFilter("mode", mode.key)}
|
||||
onClick={() => updateFilter("scan_mode", mode.key)}
|
||||
title={isEn ? mode.descEn : mode.descZh}
|
||||
>
|
||||
<Icon size={16} />
|
||||
@@ -124,17 +115,17 @@ export function ScanFilterPanel({
|
||||
{isEn ? "Price Range" : "价格范围"}
|
||||
</span>
|
||||
<div className="scan-range-display">
|
||||
<span>{filters.priceRange[0].toFixed(2)}</span>
|
||||
<span>{value.min_price.toFixed(2)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={filters.priceRange[0] * 100}
|
||||
value={value.min_price * 100}
|
||||
onChange={(e) =>
|
||||
updateFilter("priceRange", [
|
||||
Number(e.target.value) / 100,
|
||||
filters.priceRange[1],
|
||||
])
|
||||
updateFilter(
|
||||
"min_price",
|
||||
Math.min(Number(e.target.value) / 100, value.max_price),
|
||||
)
|
||||
}
|
||||
className="scan-range-slider"
|
||||
/>
|
||||
@@ -142,16 +133,16 @@ export function ScanFilterPanel({
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={filters.priceRange[1] * 100}
|
||||
value={value.max_price * 100}
|
||||
onChange={(e) =>
|
||||
updateFilter("priceRange", [
|
||||
filters.priceRange[0],
|
||||
Number(e.target.value) / 100,
|
||||
])
|
||||
updateFilter(
|
||||
"max_price",
|
||||
Math.max(Number(e.target.value) / 100, value.min_price),
|
||||
)
|
||||
}
|
||||
className="scan-range-slider"
|
||||
/>
|
||||
<span>{filters.priceRange[1].toFixed(2)}</span>
|
||||
<span>{value.max_price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -161,13 +152,15 @@ export function ScanFilterPanel({
|
||||
{isEn ? "Min Edge" : "最小边际优势"}
|
||||
</span>
|
||||
<div className="scan-range-display">
|
||||
<span>{filters.minEdge}%</span>
|
||||
<span>{value.min_edge_pct}%</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={20}
|
||||
value={filters.minEdge}
|
||||
onChange={(e) => updateFilter("minEdge", Number(e.target.value))}
|
||||
value={value.min_edge_pct}
|
||||
onChange={(e) =>
|
||||
updateFilter("min_edge_pct", Number(e.target.value))
|
||||
}
|
||||
className="scan-range-slider"
|
||||
/>
|
||||
</div>
|
||||
@@ -179,10 +172,17 @@ export function ScanFilterPanel({
|
||||
{isEn ? "High Liquidity Only" : "只看高流动性"}
|
||||
</span>
|
||||
<button
|
||||
className={`scan-toggle ${filters.highLiquidityOnly ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
updateFilter("highLiquidityOnly", !filters.highLiquidityOnly)
|
||||
}
|
||||
className={`scan-toggle ${value.high_liquidity_only ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
const nextValue = !value.high_liquidity_only;
|
||||
onChange?.({
|
||||
...value,
|
||||
high_liquidity_only: nextValue,
|
||||
min_liquidity: nextValue
|
||||
? Math.max(value.min_liquidity, 5000)
|
||||
: 500,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="scan-toggle-knob" />
|
||||
</button>
|
||||
@@ -195,11 +195,11 @@ export function ScanFilterPanel({
|
||||
</span>
|
||||
<select
|
||||
className="scan-select"
|
||||
value={filters.marketType}
|
||||
value={value.market_type}
|
||||
onChange={(e) =>
|
||||
updateFilter(
|
||||
"marketType",
|
||||
e.target.value as FilterState["marketType"],
|
||||
"market_type",
|
||||
e.target.value as FilterState["market_type"],
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -217,11 +217,11 @@ export function ScanFilterPanel({
|
||||
</span>
|
||||
<select
|
||||
className="scan-select"
|
||||
value={filters.timeRange}
|
||||
value={value.time_range}
|
||||
onChange={(e) =>
|
||||
updateFilter(
|
||||
"timeRange",
|
||||
e.target.value as FilterState["timeRange"],
|
||||
"time_range",
|
||||
e.target.value as FilterState["time_range"],
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -235,7 +235,7 @@ export function ScanFilterPanel({
|
||||
{/* === CTA === */}
|
||||
<button
|
||||
className="scan-cta-button"
|
||||
onClick={() => onScan?.(filters)}
|
||||
onClick={() => onScan?.(value)}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<Search size={16} />
|
||||
|
||||
@@ -3,17 +3,9 @@
|
||||
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";
|
||||
|
||||
interface KPIData {
|
||||
recommendedCount: number;
|
||||
recommendedDelta?: number;
|
||||
avgEdge: number | null;
|
||||
avgEdgeDelta?: number | null;
|
||||
totalWinRate: number | null;
|
||||
tradableMarkets: number;
|
||||
filteredTotal: number;
|
||||
totalVolume: number;
|
||||
}
|
||||
type KPIData = ScanTerminalResponse["summary"];
|
||||
|
||||
function formatVolume(value: number): string {
|
||||
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
|
||||
@@ -29,42 +21,41 @@ export function ScanKPIBar({ data }: { data: KPIData }) {
|
||||
{
|
||||
icon: Target,
|
||||
label: isEn ? "Recommended" : "推荐机会",
|
||||
value: String(data.recommendedCount),
|
||||
delta:
|
||||
data.recommendedDelta != null
|
||||
? `${isEn ? "vs last" : "较上次"} ${data.recommendedDelta > 0 ? "+" : ""}${data.recommendedDelta}`
|
||||
: null,
|
||||
value: String(data.recommended_count),
|
||||
delta: `${isEn ? "Showing" : "当前展示"} ${data.visible_count}`,
|
||||
accent: "green" as const,
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
label: isEn ? "Avg Edge" : "平均边际优势",
|
||||
value: data.avgEdge != null ? `+${data.avgEdge.toFixed(1)}%` : "--",
|
||||
delta:
|
||||
data.avgEdgeDelta != null
|
||||
? `${isEn ? "vs last" : "较上次"} ${data.avgEdgeDelta > 0 ? "+" : ""}${data.avgEdgeDelta.toFixed(1)}%`
|
||||
: null,
|
||||
value:
|
||||
data.avg_edge_percent != null
|
||||
? `+${data.avg_edge_percent.toFixed(1)}%`
|
||||
: "--",
|
||||
delta: `${isEn ? "Candidates" : "候选总数"} ${data.candidate_total}`,
|
||||
accent: "green" as const,
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
label: isEn ? "Total Win Rate" : "总胜率预测",
|
||||
label: isEn ? "Avg Confidence" : "平均主信号置信度",
|
||||
value:
|
||||
data.totalWinRate != null ? `+${data.totalWinRate.toFixed(1)}%` : "--",
|
||||
delta: isEn ? "Model confidence" : "模型全分配",
|
||||
data.avg_primary_confidence != null
|
||||
? `${data.avg_primary_confidence.toFixed(0)}`
|
||||
: "--",
|
||||
delta: isEn ? "Main signal score" : "主信号评分",
|
||||
accent: "green" as const,
|
||||
},
|
||||
{
|
||||
icon: Radio,
|
||||
label: isEn ? "Tradable Markets" : "可交易市场",
|
||||
value: String(data.tradableMarkets),
|
||||
delta: `${isEn ? "Filtered" : "过滤后"} / ${data.filteredTotal}`,
|
||||
value: String(data.tradable_market_count),
|
||||
delta: data.resolved_market_type || "maxtemp",
|
||||
accent: "purple" as const,
|
||||
},
|
||||
{
|
||||
icon: DollarSign,
|
||||
label: isEn ? "Total Volume" : "总成交量",
|
||||
value: formatVolume(data.totalVolume),
|
||||
value: formatVolume(data.total_volume),
|
||||
delta: isEn ? "Past 24h" : "过去 24 小时",
|
||||
accent: "purple" as const,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
startTransition,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
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 { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||
import { dashboardClient } from "@/lib/dashboard-client";
|
||||
import type {
|
||||
MarketScan,
|
||||
PrimarySignal,
|
||||
ScanOpportunityRow,
|
||||
ScanTerminalResponse,
|
||||
} from "@/lib/dashboard-types";
|
||||
import {
|
||||
getLocalizedAirportName,
|
||||
getLocalizedCityName,
|
||||
} from "@/lib/dashboard-home-copy";
|
||||
|
||||
const DEFAULT_FILTERS: FilterState = {
|
||||
scan_mode: "tradable",
|
||||
min_price: 0.05,
|
||||
max_price: 0.95,
|
||||
min_edge_pct: 2,
|
||||
min_liquidity: 500,
|
||||
high_liquidity_only: false,
|
||||
market_type: "maxtemp",
|
||||
time_range: "today",
|
||||
limit: 25,
|
||||
};
|
||||
|
||||
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)}%`;
|
||||
}
|
||||
|
||||
function formatProbability(value?: number | null) {
|
||||
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||
return `${(Number(value) * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function formatPrice(value?: number | null) {
|
||||
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||
return `${Math.round(Number(value) * 100)}¢`;
|
||||
}
|
||||
|
||||
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 formatMinutes(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} 分钟`;
|
||||
}
|
||||
|
||||
function scoreTone(score?: number | null) {
|
||||
const numeric = Number(score || 0);
|
||||
if (numeric >= 85) return "green";
|
||||
if (numeric >= 70) return "amber";
|
||||
return "red";
|
||||
}
|
||||
|
||||
function confidenceDotCount(score?: number | null) {
|
||||
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;
|
||||
}
|
||||
|
||||
function getSideRow(
|
||||
marketScan: MarketScan | null | undefined,
|
||||
selectedRow: ScanOpportunityRow,
|
||||
side: "yes" | "no",
|
||||
) {
|
||||
const rows = Array.isArray(marketScan?.scan_rows) ? marketScan?.scan_rows : [];
|
||||
const matched = rows.find(
|
||||
(row) =>
|
||||
row.market_slug === selectedRow.market_slug &&
|
||||
row.selected_date === selectedRow.selected_date &&
|
||||
row.side === side,
|
||||
);
|
||||
if (matched) return matched;
|
||||
if (selectedRow.side === side) return selectedRow;
|
||||
return null;
|
||||
}
|
||||
|
||||
function DetailPanel({
|
||||
row,
|
||||
marketScan,
|
||||
loading,
|
||||
}: {
|
||||
row: ScanOpportunityRow | null;
|
||||
marketScan?: MarketScan | null;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
if (!row) {
|
||||
return (
|
||||
<aside className="scan-detail-panel">
|
||||
<div className="scan-detail-empty">
|
||||
{isEn ? "Select a row to inspect the main signal." : "选择一条机会,查看主信号详情。"}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const localizedCityName = getLocalizedCityName(
|
||||
row.city,
|
||||
row.city_display_name || row.display_name || row.city,
|
||||
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 yesRow = getSideRow(marketScan, row, "yes");
|
||||
const noRow = getSideRow(marketScan, row, "no");
|
||||
const tone = scoreTone(displayRow.final_score);
|
||||
const filledDots = confidenceDotCount(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>
|
||||
<div className="scan-detail-volume">
|
||||
{formatVolume(displayRow.volume)}{" "}
|
||||
{isEn ? "24h volume" : "24h 成交量"}
|
||||
{loading ? ` · ${isEn ? "loading" : "载入中"}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{isEn ? "Current Temp" : "当前温度"}
|
||||
</span>
|
||||
<span className="scan-condition-value">
|
||||
{row.current_temp != null ? `${row.current_temp}${row.temp_symbol || ""}` : "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{isEn ? "Day High (So Far)" : "今日高点(至今)"}
|
||||
</span>
|
||||
<span className="scan-condition-value">
|
||||
{row.current_max_so_far != null
|
||||
? `${row.current_max_so_far}${row.temp_symbol || ""}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{isEn ? "Target" : "目标温度"}
|
||||
</span>
|
||||
<span className="scan-condition-value">
|
||||
{displayRow.target_label || "--"}
|
||||
</span>
|
||||
</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",
|
||||
)}
|
||||
>
|
||||
{displayRow.gap_to_target != null
|
||||
? `${displayRow.gap_to_target >= 0 ? "+" : ""}${displayRow.gap_to_target.toFixed(1)}${row.temp_symbol || ""}`
|
||||
: "--"}
|
||||
</span>
|
||||
</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>
|
||||
<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)
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{isEn ? "Airport" : "机场锚点"}
|
||||
</span>
|
||||
<span className="scan-condition-value">{localizedAirport || "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scan-detail-section">
|
||||
<div className="scan-detail-section-title">
|
||||
{isEn ? "Recommended Trade" : "推荐交易"}
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ScanTerminalScreen() {
|
||||
const store = useDashboardStore();
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
const [draftFilters, setDraftFilters] = useState<FilterState>(DEFAULT_FILTERS);
|
||||
const [activeFilters, setActiveFilters] = useState<FilterState>(DEFAULT_FILTERS);
|
||||
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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
|
||||
);
|
||||
}, [deferredRows, selectedRowId]);
|
||||
|
||||
const fetchTerminal = useEffectEvent(async (filters: FilterState, force = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await dashboardClient.getScanTerminal(filters, { force });
|
||||
startTransition(() => {
|
||||
setTerminalData(response);
|
||||
setActiveFilters(filters);
|
||||
setSelectedRowId((current) => {
|
||||
if (current && response.rows.some((row) => row.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return response.top_signal?.id || response.rows[0]?.id || null;
|
||||
});
|
||||
});
|
||||
} catch (fetchError) {
|
||||
setError(String(fetchError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const fetchDetail = useEffectEvent(async (row: ScanOpportunityRow) => {
|
||||
if (!row.market_slug || !row.selected_date) return;
|
||||
if (detailByRowId[row.id] !== undefined) return;
|
||||
setDetailLoadingId(row.id);
|
||||
try {
|
||||
const response = await dashboardClient.getCityMarketScan(row.city, {
|
||||
force: false,
|
||||
marketSlug: row.market_slug,
|
||||
targetDate: row.selected_date,
|
||||
});
|
||||
setDetailByRowId((current) => ({
|
||||
...current,
|
||||
[row.id]: response.market_scan || null,
|
||||
}));
|
||||
} catch {
|
||||
setDetailByRowId((current) => ({
|
||||
...current,
|
||||
[row.id]: null,
|
||||
}));
|
||||
} finally {
|
||||
setDetailLoadingId((current) => (current === row.id ? null : current));
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void fetchTerminal(DEFAULT_FILTERS, false);
|
||||
}, [fetchTerminal]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
void fetchTerminal(activeFilters, false);
|
||||
}, 30_000);
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [activeFilters, fetchTerminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRow) return;
|
||||
void fetchDetail(selectedRow);
|
||||
}, [selectedRow, fetchDetail]);
|
||||
|
||||
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="scan-terminal">
|
||||
<ScanFilterPanel
|
||||
value={draftFilters}
|
||||
onChange={setDraftFilters}
|
||||
onScan={(filters) => {
|
||||
setDraftFilters(filters);
|
||||
void fetchTerminal(filters, true);
|
||||
}}
|
||||
isScanning={loading}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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)
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScanKPIBar
|
||||
data={
|
||||
terminalData?.summary || {
|
||||
recommended_count: 0,
|
||||
visible_count: 0,
|
||||
candidate_total: 0,
|
||||
avg_edge_percent: null,
|
||||
avg_primary_confidence: null,
|
||||
tradable_market_count: 0,
|
||||
total_volume: 0,
|
||||
resolved_market_type: "maxtemp",
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
<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}
|
||||
</div>
|
||||
) : (
|
||||
<OpportunityTable
|
||||
rows={deferredRows}
|
||||
selectedRowId={selectedRowId}
|
||||
onSelectRow={(row) => setSelectedRowId(row.id)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<DetailPanel
|
||||
row={selectedRow}
|
||||
marketScan={selectedDetail}
|
||||
loading={detailLoadingId === selectedRow?.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScanTerminalDashboard() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<DashboardStoreProvider>
|
||||
<ScanTerminalScreen />
|
||||
</DashboardStoreProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user