From ddc0180ae6fd02dff5677d7afddd415fa430f2ae Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Thu, 23 Apr 2026 23:49:52 +0800
Subject: [PATCH] Implement EMOS scan terminal with REST-only market data
---
frontend/app/api/scan/terminal/route.ts | 65 +
.../components/dashboard/DashboardEntry.tsx | 8 +-
.../components/dashboard/OpportunityTable.tsx | 298 +-
.../dashboard/PolyWeatherDashboard.tsx | 3315 +----------------
.../components/dashboard/ScanFilterPanel.tsx | 98 +-
frontend/components/dashboard/ScanKPIBar.tsx | 43 +-
.../dashboard/ScanTerminalDashboard.tsx | 515 +++
frontend/lib/dashboard-client.ts | 38 +
frontend/lib/dashboard-types.ts | 128 +
src/data_collection/polymarket_readonly.py | 961 ++++-
tests/test_polymarket_readonly.py | 190 +
tests/test_web_observability.py | 64 +
web/analysis_service.py | 13 +
web/routes.py | 34 +
web/scan_terminal_service.py | 358 ++
15 files changed, 2543 insertions(+), 3585 deletions(-)
create mode 100644 frontend/app/api/scan/terminal/route.ts
create mode 100644 frontend/components/dashboard/ScanTerminalDashboard.tsx
create mode 100644 web/scan_terminal_service.py
diff --git a/frontend/app/api/scan/terminal/route.ts b/frontend/app/api/scan/terminal/route.ts
new file mode 100644
index 00000000..553f6bf4
--- /dev/null
+++ b/frontend/app/api/scan/terminal/route.ts
@@ -0,0 +1,65 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ applyAuthResponseCookies,
+ buildBackendRequestHeaders,
+} from "@/lib/backend-auth";
+
+const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
+
+export async function GET(req: NextRequest) {
+ if (!API_BASE) {
+ return NextResponse.json(
+ { error: "POLYWEATHER_API_BASE_URL is not configured" },
+ { status: 500 },
+ );
+ }
+
+ const params = new URLSearchParams();
+ for (const key of [
+ "scan_mode",
+ "min_price",
+ "max_price",
+ "min_edge_pct",
+ "min_liquidity",
+ "high_liquidity_only",
+ "market_type",
+ "time_range",
+ "limit",
+ "force_refresh",
+ ]) {
+ const value = req.nextUrl.searchParams.get(key);
+ if (value != null && value !== "") {
+ params.set(key, value);
+ }
+ }
+
+ const url = `${API_BASE}/api/scan/terminal?${params.toString()}`;
+
+ try {
+ const auth = await buildBackendRequestHeaders(req);
+ const res = await fetch(url, {
+ headers: auth.headers,
+ cache: "no-store",
+ });
+ if (!res.ok) {
+ const raw = await res.text();
+ const response = NextResponse.json(
+ { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
+ { status: 502 },
+ );
+ return applyAuthResponseCookies(response, auth.response);
+ }
+ const data = await res.json();
+ const response = NextResponse.json(data, {
+ headers: {
+ "Cache-Control": "no-store",
+ },
+ });
+ return applyAuthResponseCookies(response, auth.response);
+ } catch (error) {
+ return NextResponse.json(
+ { error: "Failed to fetch scan terminal data", detail: String(error) },
+ { status: 500 },
+ );
+ }
+}
diff --git a/frontend/components/dashboard/DashboardEntry.tsx b/frontend/components/dashboard/DashboardEntry.tsx
index ee914549..74f53fda 100644
--- a/frontend/components/dashboard/DashboardEntry.tsx
+++ b/frontend/components/dashboard/DashboardEntry.tsx
@@ -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 ;
+ return ;
}
diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx
index 5bdafc02..355fdbf3 100644
--- a/frontend/components/dashboard/OpportunityTable.tsx
+++ b/frontend/components/dashboard/OpportunityTable.tsx
@@ -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 --;
- const maxProb = Math.max(...buckets.map((b) => b.probability), 0.01);
- return (
-
- {buckets.map((bucket, i) => (
-
-
-
- {bucket.label.replace(/[°CF]/g, "")}
-
-
- ))}
-
- );
-}
-
-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 (
- {/* Header */}
#
{isEn ? "City / Market" : "城市 / 市场"}
- {isEn ? "Local Time / Status" : "当前时间 / 阶段"}
+ {isEn ? "Time / Phase" : "时间 / 阶段"}
- {isEn ? "Prob. Dist. vs Market" : "模型分布 vs 市场分布"}
+ {isEn ? "EMOS vs Market" : "EMOS vs 市场"}
- {isEn ? "Best Action" : "最佳机会"}
+ {isEn ? "Best Action" : "最佳动作"}
{isEn ? "Edge" : "边际优势"}
@@ -222,90 +131,99 @@ export function OpportunityTable({ rows }: { rows: OpportunityRow[] }) {
- {/* 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 (
-
store.focusCity(row.city.name)}
+
+
);
})}
+
+ {!rows.length ? (
+
+
+
+ {isEn ? "No primary signal" : "当前无主信号"}
+
+
+ {isEn
+ ? "No opportunity passed the price, spread, liquidity, and edge filters."
+ : "当前没有机会同时满足价格、点差、流动性和 edge 过滤。"}
+
+
+
+ ) : null}
);
}
diff --git a/frontend/components/dashboard/PolyWeatherDashboard.tsx b/frontend/components/dashboard/PolyWeatherDashboard.tsx
index e950404b..54c7e0da 100644
--- a/frontend/components/dashboard/PolyWeatherDashboard.tsx
+++ b/frontend/components/dashboard/PolyWeatherDashboard.tsx
@@ -1,3318 +1,7 @@
"use client";
-import clsx from "clsx";
-import dynamic from "next/dynamic";
-import Image from "next/image";
-import Link from "next/link";
-import {
- useEffect,
- useMemo,
- useRef,
- useState,
- type FormEvent,
- type PointerEvent as ReactPointerEvent,
-} from "react";
-import { Search } from "lucide-react";
-import styles from "./Dashboard.module.css";
-import detailChromeStyles from "./DetailPanelChrome.module.css";
-import modalChromeStyles from "./ModalChrome.module.css";
-import {
- DashboardStoreProvider,
- useDashboardStore,
-} from "@/hooks/useDashboardStore";
-import { I18nProvider, useI18n } from "@/hooks/useI18n";
-import { CitySidebar } from "@/components/dashboard/CitySidebar";
-import { HeaderBar } from "@/components/dashboard/HeaderBar";
-import { ScanFilterPanel } from "@/components/dashboard/ScanFilterPanel";
-import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar";
-import { OpportunityTable } from "@/components/dashboard/OpportunityTable";
-import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
-import type {
- CityDetail,
- CityListItem,
- CitySummary,
- MarketScan,
- MarketTopBucket,
- ProbabilityBucket,
- RiskLevel,
-} from "@/lib/dashboard-types";
-import type {
- AssistantContextPayload,
- AssistantOpportunityContext,
-} from "@/lib/dashboard-client";
-import { dashboardClient, getCityRevision } from "@/lib/dashboard-client";
-import {
- getLocalizedAirportDisplay,
- getLocalizedCityDisplay,
-} from "@/lib/dashboard-home-copy";
-import {
- getTemperatureChartData,
- getWeatherSummary,
-} from "@/lib/dashboard-utils";
-const loadHistoryModal = () =>
- import("@/components/dashboard/HistoryModal").then(
- (module) => module.HistoryModal,
- );
-
-const loadFutureForecastModal = () =>
- import("@/components/dashboard/FutureForecastModal").then(
- (module) => module.FutureForecastModal,
- );
-
-const MapCanvas = dynamic(
- () =>
- import("@/components/dashboard/MapCanvas").then(
- (module) => module.MapCanvas,
- ),
- {
- ssr: false,
- loading: () => ,
- },
-);
-
-const HistoryModal = dynamic(loadHistoryModal, {
- ssr: false,
- loading: () => null,
-});
-
-const FutureForecastModal = dynamic(loadFutureForecastModal, {
- ssr: false,
- loading: () => null,
-});
-
-type CitySnapshot = {
- city: CityListItem;
- detail?: CityDetail | null;
- score: number;
- summary?: CitySummary | null;
- tradableOpportunity: boolean;
-};
-
-const RISK_SCORE: Record = {
- high: 3,
- medium: 2,
- low: 1,
-};
-
-function getRiskCopy(level: RiskLevel | undefined, locale: string) {
- if (level === "high") return locale === "en-US" ? "High variance" : "高波动";
- if (level === "medium") return locale === "en-US" ? "Watch list" : "重点观察";
- if (level === "low") return locale === "en-US" ? "Stable" : "低波动";
- return locale === "en-US" ? "Unrated" : "待评级";
-}
-
-function getTempSymbol(
- city: CityListItem,
- summary?: CitySummary | null,
- detail?: CityDetail | null,
-) {
- if (summary?.temp_symbol) return summary.temp_symbol;
- if (detail?.temp_symbol) return detail.temp_symbol;
- return city.temp_unit === "fahrenheit" ? "°F" : "°C";
-}
-
-function formatTemperature(value: number | null | undefined, symbol: string) {
- if (!Number.isFinite(Number(value))) return "--";
- return `${Math.round(Number(value))}${symbol}`;
-}
-
-function formatPercent(value: number | null | undefined) {
- if (!Number.isFinite(Number(value))) return "--";
- return `${Math.round(Number(value) * 100)}%`;
-}
-
-function formatDelta(
- current: number | null | undefined,
- forecast: number | null | undefined,
- symbol: string,
-) {
- if (!Number.isFinite(Number(current)) || !Number.isFinite(Number(forecast))) {
- return "--";
- }
- const delta = Number(current) - Number(forecast);
- const sign = delta > 0 ? "+" : "";
- return `${sign}${delta.toFixed(1)}${symbol}`;
-}
-
-function buildSnapshot(
- city: CityListItem,
- summary?: CitySummary | null,
- detail?: CityDetail | null,
-): CitySnapshot {
- const tier =
- city.deb_recent_tier ||
- city.risk_level ||
- summary?.risk?.level ||
- detail?.risk?.level;
- const hitRate = Number(city.deb_recent_hit_rate ?? 0);
- const sampleCount = Number(city.deb_recent_sample_count ?? 0);
- const normalizedEdge = normalizeEdgePercent(getMarketEdgeValue(detail));
- const tradableOpportunity = isTradableMarketOpportunity(detail);
- const score =
- (detail?.market_scan && !tradableOpportunity ? -10_000 : 0) +
- (tradableOpportunity && normalizedEdge != null
- ? 1000 + normalizedEdge * 10
- : 0) +
- (RISK_SCORE[String(tier || "")] || 0) * 100 +
- hitRate * 100 +
- Math.min(sampleCount, 60) / 10;
- return { city, detail, score, summary, tradableOpportunity };
-}
-
-function getProbabilityLabel(
- bucket: {
- label?: string | null;
- value?: number | null;
- bucket?: string | null;
- },
- symbol: string,
-) {
- if (bucket.label)
- return normalizeTemperatureBucketLabel(bucket.label, symbol);
- if (bucket.bucket)
- return normalizeTemperatureBucketLabel(bucket.bucket, symbol);
- if (Number.isFinite(Number(bucket.value))) {
- return `≥ ${Math.round(Number(bucket.value))}${symbol}`;
- }
- return "--";
-}
-
-function normalizeTemperatureBucketLabel(label: string, symbol: string) {
- const normalizedSymbol = symbol.includes("F") ? "°F" : "°C";
- return String(label || "")
- .trim()
- .replace(
- /(-?\d+(?:\.\d+)?)\s*°?\s*[CF]\b/gi,
- (_, value) => `${Math.round(Number(value))}${normalizedSymbol}`,
- );
-}
-
-function parseBucketThreshold(bucket?: {
- label?: string | null;
- value?: number | null;
- temp?: number | null;
- bucket?: string | null;
- range?: string | null;
-}) {
- const directValue = bucket?.value ?? bucket?.temp;
- if (Number.isFinite(Number(directValue))) return Number(directValue);
- const text = String(bucket?.label || bucket?.bucket || bucket?.range || "");
- const match = text.match(/-?\d+(?:\.\d+)?/);
- if (!match) return null;
- const parsed = Number(match[0]);
- return Number.isFinite(parsed) ? parsed : null;
-}
-
-function getBucketMatchKey(bucket?: {
- label?: string | null;
- value?: number | null;
- temp?: number | null;
- bucket?: string | null;
- range?: string | null;
-}) {
- const threshold = parseBucketThreshold(bucket);
- if (threshold != null) return `t:${threshold.toFixed(2)}`;
- return `l:${String(bucket?.label || bucket?.bucket || bucket?.range || "")
- .trim()
- .toLowerCase()}`;
-}
-
-function normalizeProbabilityValue(value: number | null | undefined) {
- if (!Number.isFinite(Number(value))) return null;
- return Number(value);
-}
-
-function getDisplayScore(rawScore: number): number {
- if (rawScore <= 0) return Math.max(0, 70 + rawScore / 1000); // Penalty cases
- // Scale the positive scores to 80-100
- // rawScore for tradable is roughly 1000 + edge*10 + risk*100 + hitRate*100
- // typical high score is 1000 + 50 + 400 + 90 = 1540
- if (rawScore > 1000) {
- return Math.min(100, 80 + (rawScore - 1000) / 30);
- }
- return Math.min(80, Math.max(0, (rawScore / 1000) * 80));
-}
-
-function buildMarketAlignedProbabilities(
- marketScan: MarketScan | null | undefined,
- probabilityBuckets: ProbabilityBucket[],
- symbol: string,
-) {
- const modelByKey = new Map(
- probabilityBuckets.map((bucket) => [getBucketMatchKey(bucket), bucket]),
- );
- const marketBuckets: Array = [
- ...(Array.isArray(marketScan?.all_buckets) ? marketScan.all_buckets : []),
- ...(Array.isArray(marketScan?.top_buckets) ? marketScan.top_buckets : []),
- ...(marketScan?.temperature_bucket ? [marketScan.temperature_bucket] : []),
- ];
- const aligned: ProbabilityBucket[] = [];
- const seen = new Set();
-
- for (const marketBucket of marketBuckets) {
- const key = getBucketMatchKey(marketBucket);
- if (!key || seen.has(key)) continue;
- seen.add(key);
-
- const matchedModelBucket = modelByKey.get(key);
- aligned.push({
- label: getProbabilityLabel(marketBucket, symbol),
- value:
- marketBucket.value ??
- ("temp" in marketBucket ? marketBucket.temp : undefined) ??
- matchedModelBucket?.value ??
- null,
- bucket:
- ("bucket" in marketBucket ? marketBucket.bucket : undefined) ??
- matchedModelBucket?.bucket ??
- null,
- range:
- ("range" in marketBucket ? marketBucket.range : undefined) ??
- matchedModelBucket?.range ??
- null,
- unit:
- marketBucket.unit ??
- matchedModelBucket?.unit ??
- (symbol === "°F" ? "F" : "C"),
- probability:
- normalizeProbabilityValue(matchedModelBucket?.probability) ?? null,
- });
- }
-
- return aligned.length ? aligned.slice(0, 4) : probabilityBuckets.slice(0, 4);
-}
-
-function formatProbability(value: number | null | undefined) {
- if (!Number.isFinite(Number(value))) return "--";
- const normalized =
- Math.abs(Number(value)) <= 1 ? Number(value) * 100 : Number(value);
- return `${Math.round(normalized)}%`;
-}
-
-function formatCents(value: number | null | undefined) {
- if (!Number.isFinite(Number(value))) return "--";
- const normalized =
- Math.abs(Number(value)) <= 1 ? Number(value) * 100 : Number(value);
- return `${Math.round(normalized)}¢`;
-}
-
-function formatEdge(value: number | null | undefined) {
- if (!Number.isFinite(Number(value))) return "--";
- const normalized =
- Math.abs(Number(value)) <= 1 ? Number(value) * 100 : Number(value);
- const sign = normalized > 0 ? "+" : "";
- return `${sign}${normalized.toFixed(1)}%`;
-}
-
-function getScanStatusLabel(detail?: CityDetail | null): string {
- const scan = detail?.market_scan;
- if (!scan?.available) return "无盘口";
- 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 "触达博弈";
- if (edgeVal != null && edgeVal >= 0.02) return "即时确认";
- if (edgeVal != null && edgeVal > 0) return "早期机会";
- return "市场";
-}
-
-function formatVolume(value?: number | null): string {
- if (value == null) return "--";
- if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
- if (value >= 1_000) return `$${Math.round(value / 1_000)}K`;
- return `$${value.toFixed(0)}`;
-}
-
-function normalizeEdgePercent(value: number | null | undefined) {
- if (!Number.isFinite(Number(value))) return null;
- const numeric = Number(value);
- return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
-}
-
-function getBestSide(detail?: CityDetail | null) {
- const bestSide = String(
- detail?.market_scan?.price_analysis?.best_side || "",
- ).toLowerCase();
- if (bestSide.includes("yes")) return "yes";
- if (bestSide.includes("no")) return "no";
- return null;
-}
-
-function getSideEdgePercent(
- detail: CityDetail | null | undefined,
- side: "yes" | "no",
-) {
- const sideAnalysis = detail?.market_scan?.price_analysis?.[side];
- return normalizeEdgePercent(sideAnalysis?.edge_percent ?? sideAnalysis?.edge);
-}
-
-function getExecutableEdgePercent(detail?: CityDetail | null) {
- const bestSide = getBestSide(detail);
- if (bestSide) {
- const bestEdge = getSideEdgePercent(detail, bestSide);
- if (bestEdge != null) return bestEdge;
- }
-
- const sideEdges = [
- getSideEdgePercent(detail, "yes"),
- getSideEdgePercent(detail, "no"),
- ].filter((value): value is number => value != null && value > 0);
- if (sideEdges.length) return Math.max(...sideEdges);
-
- return normalizeEdgePercent(detail?.market_scan?.edge_percent);
-}
-
-function getBestSideModelProbability(detail?: CityDetail | null) {
- const marketScan = detail?.market_scan;
- const pYes = normalizeProbabilityValue(
- marketScan?.price_analysis?.model_probability ??
- marketScan?.model_probability ??
- marketScan?.temperature_bucket?.probability,
- );
- if (pYes == null) return null;
- return getBestSide(detail) === "no" ? 1 - pYes : pYes;
-}
-
-function getBestSideAsk(detail?: CityDetail | null) {
- const marketScan = detail?.market_scan;
- const side = getBestSide(detail);
- if (side === "yes") {
- return (
- marketScan?.yes_buy ??
- marketScan?.yes_token?.buy_price ??
- marketScan?.price_analysis?.yes?.ask
- );
- }
- if (side === "no") {
- return (
- marketScan?.no_buy ??
- marketScan?.no_token?.buy_price ??
- marketScan?.price_analysis?.no?.ask
- );
- }
- return marketScan?.market_price ?? marketScan?.midpoint ?? null;
-}
-
-function getTradeSideLabel(side: "yes" | "no" | null, locale: string) {
- if (side === "yes") return locale === "en-US" ? "BUY YES" : "买 YES";
- if (side === "no") return locale === "en-US" ? "BUY NO" : "买 NO";
- return locale === "en-US" ? "WATCH" : "观察";
-}
-
-function getTradeFormulaLabel(side: "yes" | "no" | null, locale: string) {
- if (side === "no") {
- return locale === "en-US"
- ? "Edge = EMOS P(NO) - NO ask"
- : "Edge = EMOS P(NO) - NO 买价";
- }
- return locale === "en-US"
- ? "Edge = EMOS P(YES) - YES ask"
- : "Edge = EMOS P(YES) - YES 买价";
-}
-
-function isLiveMarketScan(detail?: CityDetail | null) {
- const marketScan = detail?.market_scan;
- if (!marketScan) return false;
- if (marketScan.available === false) return false;
- if (marketScan.primary_market?.closed === true) return false;
- if (marketScan.primary_market?.active === false) return false;
- if (marketScan.selected_date && detail?.local_date) {
- if (marketScan.selected_date < detail.local_date) return false;
- }
- const endDateMs = marketScan.primary_market?.end_date
- ? Date.parse(marketScan.primary_market.end_date)
- : Number.NaN;
- if (Number.isFinite(endDateMs) && endDateMs < Date.now()) return false;
- return true;
-}
-
-function buildSparklinePoints(values: number[] | undefined) {
- if (!values?.length) return "";
- const width = 92;
- const height = 28;
- const min = Math.min(...values);
- const max = Math.max(...values);
- const span = max - min || 1;
- return values
- .map((value, index) => {
- const x =
- values.length === 1 ? width : (index / (values.length - 1)) * width;
- const y = height - ((value - min) / span) * height;
- return `${x.toFixed(1)},${y.toFixed(1)}`;
- })
- .join(" ");
-}
-
-type AssistantMessage = {
- id: string;
- role: "assistant" | "user";
- content: string;
- cached?: boolean;
-};
-
-type OpportunityScanState = "pending" | "complete" | "empty" | "error";
-const HOME_OPPORTUNITY_REFRESH_MS = 30_000;
-
-type AssistantDockPosition = {
- right: number;
- bottom: number;
-};
-
-type AssistantDockDragState = {
- pointerId: number;
- startX: number;
- startY: number;
- right: number;
- bottom: number;
- hasMoved: boolean;
-};
-
-const HOME_AI_DOCK_POSITION_STORAGE_KEY =
- "polyweather_home_ai_dock_position_v2";
-
-function readAssistantDockPosition() {
- if (typeof window === "undefined") return null;
- try {
- const raw = window.localStorage.getItem(HOME_AI_DOCK_POSITION_STORAGE_KEY);
- if (!raw) return null;
- const parsed = JSON.parse(raw) as AssistantDockPosition | null;
- if (
- parsed &&
- Number.isFinite(Number(parsed.right)) &&
- Number.isFinite(Number(parsed.bottom))
- ) {
- return {
- right: Number(parsed.right),
- bottom: Number(parsed.bottom),
- };
- }
- } catch {
- // Ignore malformed dock position cache.
- }
- return null;
-}
-
-function writeAssistantDockPosition(position: AssistantDockPosition | null) {
- if (typeof window === "undefined") return;
- if (!position) {
- window.localStorage.removeItem(HOME_AI_DOCK_POSITION_STORAGE_KEY);
- return;
- }
- window.localStorage.setItem(
- HOME_AI_DOCK_POSITION_STORAGE_KEY,
- JSON.stringify(position),
- );
-}
-
-function getDefaultAssistantDockPosition() {
- if (typeof window === "undefined" || window.innerWidth <= 960) {
- return null;
- }
- return {
- right: 24,
- bottom: 24,
- };
-}
-
-function clampAssistantDockPosition(
- position: AssistantDockPosition | null,
- dockElement?: HTMLElement | null,
-) {
- if (typeof window === "undefined" || window.innerWidth <= 960) {
- return null;
- }
- const rect = dockElement?.getBoundingClientRect();
- const width = rect?.width || 340;
- const height = rect?.height || 88;
- const minOffset = 24;
- const maxRight = Math.max(minOffset, window.innerWidth - width - minOffset);
- const maxBottom = Math.max(
- minOffset,
- window.innerHeight - height - minOffset,
- );
- const base = position || getDefaultAssistantDockPosition();
- if (!base) return null;
- return {
- right: Math.min(maxRight, Math.max(minOffset, Number(base.right))),
- bottom: Math.min(maxBottom, Math.max(minOffset, Number(base.bottom))),
- };
-}
-
-function hashSnapshotText(source: string) {
- let hash = 5381;
- for (let index = 0; index < source.length; index += 1) {
- hash = (hash * 33) ^ source.charCodeAt(index);
- }
- return Math.abs(hash >>> 0).toString(16);
-}
-
-function normalizeAssistantPercent(value: number | null | undefined) {
- if (!Number.isFinite(Number(value))) return null;
- const numeric = Number(value);
- return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
-}
-
-function normalizeAssistantCents(value: number | null | undefined) {
- if (!Number.isFinite(Number(value))) return null;
- const numeric = Number(value);
- return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
-}
-
-function buildAssistantOpportunityContext(
- snapshot: CitySnapshot,
- locale: string,
-): AssistantOpportunityContext {
- const { city, detail, summary, tradableOpportunity } = snapshot;
- const symbol = getTempSymbol(city, summary, detail);
- const marketScan = detail?.market_scan;
- const marketBucket =
- marketScan?.temperature_bucket || marketScan?.top_buckets?.[0] || null;
- const marketQuestion =
- marketScan?.primary_market?.question ||
- (marketBucket
- ? `${getProbabilityLabel(marketBucket, symbol)} ${
- marketScan?.selected_date || detail?.local_date || ""
- }`
- : null);
- const localizedCityName = getLocalizedCityDisplay(
- city,
- locale,
- summary,
- detail,
- );
- const localizedAirport = getLocalizedAirportDisplay(city, locale, detail);
- const marketProbability =
- marketScan?.market_price ??
- readNumericField(marketBucket, "market_price") ??
- readNumericField(marketBucket, "yes_buy") ??
- marketScan?.yes_buy;
- const modelProbability =
- marketScan?.model_probability ?? marketBucket?.probability ?? null;
- return {
- city_name: city.name,
- city_display_name: localizedCityName,
- airport: localizedAirport,
- risk_level: String(
- city.deb_recent_tier ||
- city.risk_level ||
- summary?.risk?.level ||
- detail?.risk?.level ||
- "",
- ),
- tradable: tradableOpportunity,
- local_time: summary?.local_time || detail?.local_time || null,
- current_temperature:
- summary?.current?.temp ?? detail?.current?.temp ?? null,
- deb_prediction: summary?.deb?.prediction ?? detail?.deb?.prediction ?? null,
- temp_symbol: symbol,
- today_high:
- detail?.forecast?.today_high ??
- detail?.current?.max_so_far ??
- detail?.airport_current?.max_so_far ??
- null,
- market_question: marketQuestion,
- market_label: marketBucket
- ? getProbabilityLabel(marketBucket, symbol)
- : null,
- selected_date: marketScan?.selected_date || detail?.local_date || null,
- best_side: marketScan?.price_analysis?.best_side || null,
- yes_price: normalizeAssistantCents(
- marketScan?.yes_buy ??
- readNumericField(marketBucket, "yes_buy") ??
- marketScan?.yes_token?.buy_price ??
- marketScan?.yes_token?.midpoint,
- ),
- no_price: normalizeAssistantCents(
- marketScan?.no_buy ??
- readNumericField(marketBucket, "no_buy") ??
- marketScan?.no_token?.buy_price ??
- marketScan?.no_token?.midpoint,
- ),
- edge_percent: normalizeAssistantPercent(getMarketEdgeValue(detail)),
- market_probability: normalizeAssistantPercent(marketProbability),
- model_probability: normalizeAssistantPercent(modelProbability),
- status: tradableOpportunity
- ? "tradable"
- : marketScan
- ? "inactive"
- : "market_pending",
- };
-}
-
-function buildAssistantContextPayload(
- snapshots: CitySnapshot[],
- selectedCity: string | null,
- locale: string,
-): AssistantContextPayload {
- const opportunities = snapshots.map((snapshot) =>
- buildAssistantOpportunityContext(snapshot, locale),
- );
- const revisionSeed = snapshots
- .map((snapshot) => {
- const revision =
- getCityRevision(snapshot.detail) || getCityRevision(snapshot.summary);
- return `${snapshot.city.name}:${revision}:${snapshot.tradableOpportunity ? 1 : 0}`;
- })
- .join("|");
- const snapshotId = `home-${hashSnapshotText(revisionSeed || String(Date.now()))}`;
- const selected =
- opportunities.find((item) => item.city_name === selectedCity) || null;
-
- return {
- snapshot_id: snapshotId,
- locale,
- generated_at: new Date().toISOString(),
- totals: {
- cities: snapshots.length,
- tradable_markets: opportunities.filter((item) => item.tradable).length,
- high_risk: opportunities.filter((item) => item.risk_level === "high")
- .length,
- medium_risk: opportunities.filter((item) => item.risk_level === "medium")
- .length,
- low_risk: opportunities.filter((item) => item.risk_level === "low")
- .length,
- },
- selected_city: selected,
- opportunities,
- glossary:
- locale === "en-US"
- ? [
- {
- term: "edge",
- meaning:
- "Edge is the gap between model probability and market-implied probability. Positive edge means the model is more optimistic than the market.",
- },
- {
- term: "market probability",
- meaning:
- "Market probability is the implied probability backed out from the live YES/NO price.",
- },
- {
- term: "DEB",
- meaning:
- "DEB is the internal forecast anchor for the day-max settlement temperature.",
- },
- {
- term: "EMOS",
- meaning:
- "EMOS is the calibrated probability ladder for the 24-hour max-temperature outcome buckets.",
- },
- ]
- : [
- {
- term: "edge",
- meaning:
- "edge 是模型概率和市场隐含概率之间的差值。正值表示模型比市场更乐观。",
- },
- {
- term: "市场概率",
- meaning:
- "市场概率来自 YES/NO 实时价格的隐含概率,而不是模型输出。",
- },
- {
- term: "DEB",
- meaning: "DEB 是系统对当日结算最高温的核心预测锚点之一。",
- },
- {
- term: "EMOS",
- meaning: "EMOS 是 24 小时最高温结果分桶使用的校准概率分布。",
- },
- ],
- };
-}
-
-function buildAssistantGreeting(
- locale: string,
- selectedCityName?: string | null,
-) {
- if (locale === "en-US") {
- return selectedCityName
- ? `Ask about ${selectedCityName}'s current temperature, today's forecast high, market edge, or live opportunities.`
- : "Ask about current temperature, today's forecast high, market edge, or live opportunities.";
- }
- return selectedCityName
- ? `可以直接问我 ${selectedCityName} 的当前温度、今日最高温预测、市场 edge 或实时机会。`
- : "可以直接问我当前温度、今日最高温预测、市场 edge 或实时机会。";
-}
-
-type HomeWeatherIconKind =
- | "clear"
- | "partly"
- | "cloudy"
- | "rain"
- | "storm"
- | "mist"
- | "wind";
-
-type HomeTrendChart = {
- forecastPath: string;
- legendText: string;
- observationDots: Array<{ cx: number; cy: number; key: string }>;
- hourlyReports: Array<{
- key: string;
- label: string;
- temperatureText: string;
- }>;
- yAxisLabels: Array<{ key: string; label: string; y: number }>;
- xAxisLabels: Array<{ key: string; label: string; x: number }>;
-};
-
-type HomeForecastDay = {
- key: string;
- label: string;
- maxTemp: number;
-};
-
-type HomeSummaryCard = {
- key: string;
- title: string;
- linkLabel?: string;
- items: Array<{
- label: string;
- value: string;
- accent?: "green" | "amber" | "red" | "cyan";
- }>;
-};
-
-function buildDashboardSummaryCards(
- snapshots: CitySnapshot[],
- locale: string,
-): HomeSummaryCard[] {
- const topTradable = snapshots.filter(
- (snapshot) => snapshot.tradableOpportunity,
- );
- const highCount = snapshots.filter(
- (snapshot) => snapshot.city.deb_recent_tier === "high",
- ).length;
- const mediumCount = snapshots.filter(
- (snapshot) => snapshot.city.deb_recent_tier === "medium",
- ).length;
- const lowCount = snapshots.filter(
- (snapshot) => snapshot.city.deb_recent_tier === "low",
- ).length;
- const strongAgreement = snapshots.filter(
- (snapshot) => Number(snapshot.city.deb_recent_hit_rate ?? 0) >= 0.66,
- ).length;
- const mediumAgreement = snapshots.filter((snapshot) => {
- const rate = Number(snapshot.city.deb_recent_hit_rate ?? 0);
- return rate >= 0.4 && rate < 0.66;
- }).length;
- const weakAgreement = Math.max(
- snapshots.length - strongAgreement - mediumAgreement,
- 0,
- );
- const avgEdge = topTradable.length
- ? topTradable.reduce((sum, snapshot) => {
- const edgeValue = Number(getMarketEdgeValue(snapshot.detail));
- const normalized =
- Number.isFinite(edgeValue) && Math.abs(edgeValue) <= 1
- ? edgeValue * 100
- : edgeValue;
- return sum + (Number.isFinite(normalized) ? normalized : 0);
- }, 0) / topTradable.length
- : 0;
-
- return [
- {
- key: "opportunities",
- title: locale === "en-US" ? "Current Opportunities" : "当前机会分布",
- items: [
- {
- label: locale === "en-US" ? "High risk" : "高风险",
- value: String(highCount),
- accent: "red",
- },
- {
- label: locale === "en-US" ? "Medium risk" : "中风险",
- value: String(mediumCount),
- accent: "amber",
- },
- {
- label: locale === "en-US" ? "Low risk" : "低风险",
- value: String(lowCount),
- accent: "green",
- },
- ],
- },
- {
- key: "market-summary",
- title: locale === "en-US" ? "Market Summary" : "市场概览",
- items: [
- {
- label: locale === "en-US" ? "Total markets" : "总市场数",
- value: String(snapshots.length),
- },
- {
- label: locale === "en-US" ? "Active" : "活跃市场",
- value: String(topTradable.length),
- accent: "cyan",
- },
- {
- label: locale === "en-US" ? "Avg. edge" : "平均优势",
- value: formatEdge(avgEdge),
- accent: "green",
- },
- ],
- },
- {
- key: "model-agreement",
- title: locale === "en-US" ? "Model Agreement" : "模型一致性",
- items: [
- {
- label: locale === "en-US" ? "High" : "高",
- value: `${strongAgreement}`,
- accent: "green",
- },
- {
- label: locale === "en-US" ? "Medium" : "中",
- value: `${mediumAgreement}`,
- accent: "amber",
- },
- {
- label: locale === "en-US" ? "Low" : "低",
- value: `${weakAgreement}`,
- accent: "red",
- },
- ],
- },
- {
- key: "impact-window",
- title: locale === "en-US" ? "High Impact Window" : "高影响窗口",
- items: [
- {
- label: locale === "en-US" ? "Next 6 hours" : "未来 6 小时",
- value:
- locale === "en-US"
- ? `${Math.min(topTradable.length, 12)} markets`
- : `${Math.min(topTradable.length, 12)} 个市场`,
- },
- {
- label: locale === "en-US" ? "Focus city" : "焦点城市",
- value: topTradable[0]?.city.display_name || "--",
- accent: "cyan",
- },
- {
- label: locale === "en-US" ? "Best edge" : "最佳优势",
- value:
- topTradable.length > 0
- ? formatEdge(getMarketEdgeValue(topTradable[0]?.detail))
- : "--",
- accent: "green",
- },
- ],
- },
- ];
-}
-
-function projectHomeTrendPoint(
- x: number,
- y: number,
- xMin: number,
- xMax: number,
- yMin: number,
- yMax: number,
-) {
- const width = 296;
- const height = 78;
- const left = 10;
- const right = 10;
- const top = 8;
- const bottom = 12;
- const plotWidth = width - left - right;
- const plotHeight = height - top - bottom;
- const normalizedX =
- xMax === xMin ? 0.5 : Math.min(1, Math.max(0, (x - xMin) / (xMax - xMin)));
- const normalizedY =
- yMax === yMin ? 0.5 : Math.min(1, Math.max(0, (y - yMin) / (yMax - yMin)));
- return {
- cx: Number((left + normalizedX * plotWidth).toFixed(1)),
- cy: Number((top + (1 - normalizedY) * plotHeight).toFixed(1)),
- };
-}
-
-function getHomeWeatherIconKind(
- detail?: CityDetail | null,
- locale = "zh-CN",
-): HomeWeatherIconKind {
- if (!detail) return "cloudy";
- const summary = getWeatherSummary(
- detail,
- locale === "en-US" ? "en-US" : "zh-CN",
- );
- const weatherText =
- `${summary.weatherIcon} ${summary.weatherText} ${detail.current?.wx_desc || ""} ${detail.current?.cloud_desc || ""}`.toLowerCase();
- const cloudCover = Number(detail.hourly_next_48h?.cloud_cover?.[0]);
- const windSpeed = Number(
- detail.current?.wind_speed_kt ?? detail.airport_current?.wind_speed_kt,
- );
-
- if (/⛈|雷|storm|thunder/.test(weatherText)) return "storm";
- if (/🌧|🌦|雨|drizzle|shower|rain/.test(weatherText)) return "rain";
- if (/🌫|雾|mist|fog|haze/.test(weatherText)) return "mist";
- if (/💨|飑|squall/.test(weatherText) || windSpeed >= 22) return "wind";
- if (/☀|晴|clear|sunny/.test(weatherText)) return "clear";
- if (/🌤|⛅|partly|few|scattered|少云|散云/.test(weatherText)) return "partly";
- if (/☁|云|cloud|overcast|阴/.test(weatherText)) return "cloudy";
- if (Number.isFinite(cloudCover) && cloudCover <= 15) return "clear";
- if (Number.isFinite(cloudCover) && cloudCover <= 55) return "partly";
- return "cloudy";
-}
-
-function buildHomeTrendChart(
- detail?: CityDetail | null,
- locale = "zh-CN",
-): HomeTrendChart | null {
- if (!detail) return null;
- const chartData = getTemperatureChartData(
- detail,
- locale === "en-US" ? "en-US" : "zh-CN",
- );
- if (!chartData) return null;
- const forecastSeries = chartData.datasets.hasMgmHourly
- ? chartData.datasets.mgmHourlySeries
- : [
- ...chartData.datasets.debPastSeries,
- ...chartData.datasets.debFutureSeries,
- ];
- const observationSeries =
- chartData.datasets.metarSeries.length > 0
- ? chartData.datasets.metarSeries
- : chartData.datasets.airportMetarSeries;
- if (!forecastSeries.length && !observationSeries.length) return null;
-
- const forecastPath = forecastSeries
- .map((point) => {
- const projected = projectHomeTrendPoint(
- point.x,
- point.y,
- chartData.xMin,
- chartData.xMax,
- chartData.min,
- chartData.max,
- );
- return `${projected.cx},${projected.cy}`;
- })
- .join(" ");
- const observationDots = observationSeries.map((point, index) => {
- const projected = projectHomeTrendPoint(
- point.x,
- point.y,
- chartData.xMin,
- chartData.xMax,
- chartData.min,
- chartData.max,
- );
- return {
- cx: projected.cx,
- cy: projected.cy,
- key: `${point.labelTime}-${index}`,
- };
- });
- const hoverSource =
- observationSeries.length > 0 ? observationSeries : forecastSeries;
- const hourlyReports = hoverSource
- .filter((point) => {
- const label = String(point.labelTime || "");
- return /(^|\D)\d{1,2}:00($|\D)/.test(label) || hoverSource.length <= 8;
- })
- .slice(-6)
- .map((point, index) => ({
- key: `hourly-${point.labelTime}-${index}`,
- label: point.labelTime,
- temperatureText: formatTemperature(point.y, detail.temp_symbol || "°C"),
- }));
-
- const yAxisLabels = [
- {
- key: "max",
- label: formatTemperature(chartData.max, detail.temp_symbol || "°C"),
- y: 14,
- },
- {
- key: "mid",
- label: formatTemperature(
- (chartData.max + chartData.min) / 2,
- detail.temp_symbol || "°C",
- ),
- y: 36,
- },
- {
- key: "min",
- label: formatTemperature(chartData.min, detail.temp_symbol || "°C"),
- y: 58,
- },
- ];
-
- const xAxisSource =
- hoverSource.length <= 4
- ? hoverSource
- : [
- hoverSource[0],
- hoverSource[Math.floor((hoverSource.length - 1) / 3)],
- hoverSource[Math.floor(((hoverSource.length - 1) * 2) / 3)],
- hoverSource[hoverSource.length - 1],
- ];
-
- const xAxisLabels = xAxisSource.map((point, index) => {
- const projected = projectHomeTrendPoint(
- point.x,
- point.y,
- chartData.xMin,
- chartData.xMax,
- chartData.min,
- chartData.max,
- );
- return {
- key: `axis-${point.labelTime}-${index}`,
- label: point.labelTime,
- x: Number(((projected.cx / 296) * 100).toFixed(2)),
- };
- });
-
- return {
- forecastPath,
- legendText: chartData.legendText,
- observationDots,
- hourlyReports,
- yAxisLabels,
- xAxisLabels,
- };
-}
-
-function buildHomeForecastDays(
- detail?: CityDetail | null,
- locale = "zh-CN",
-): HomeForecastDay[] {
- if (!detail) return [];
- const todayLabel = locale === "en-US" ? "Today" : "今天";
- const tomorrowLabel = locale === "en-US" ? "Tomorrow" : "明天";
- const formatter = new Intl.DateTimeFormat(
- locale === "en-US" ? "en-US" : "zh-CN",
- {
- day: "2-digit",
- month: "2-digit",
- },
- );
- const rows = Array.isArray(detail.forecast?.daily)
- ? detail.forecast.daily
- : [];
- const byDate = new Map();
-
- if (
- detail.local_date &&
- Number.isFinite(Number(detail.forecast?.today_high))
- ) {
- byDate.set(detail.local_date, Number(detail.forecast?.today_high));
- }
- rows.forEach((row) => {
- if (!row?.date || !Number.isFinite(Number(row.max_temp))) return;
- if (!byDate.has(row.date)) {
- byDate.set(row.date, Number(row.max_temp));
- }
- });
-
- return [...byDate.entries()].slice(0, 4).map(([date, maxTemp], index) => {
- const parsed = new Date(`${date}T00:00:00`);
- const label =
- index === 0
- ? todayLabel
- : index === 1
- ? tomorrowLabel
- : Number.isNaN(parsed.getTime())
- ? date
- : formatter.format(parsed);
- return {
- key: date,
- label,
- maxTemp,
- };
- });
-}
-
-function readNumericField(source: unknown, key: string) {
- if (!source || typeof source !== "object") return undefined;
- const value = (source as Record)[key];
- return typeof value === "number" && Number.isFinite(value)
- ? value
- : undefined;
-}
-
-function normalizePercentValue(value: number | null | undefined) {
- if (!Number.isFinite(Number(value))) return null;
- const numeric = Number(value);
- return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
-}
-
-function isBoundaryMarketPrice(value: number | null | undefined) {
- const percent = normalizePercentValue(value);
- return percent != null && (percent <= 2 || percent >= 98);
-}
-
-function getMarketEdgeValue(detail?: CityDetail | null) {
- return getExecutableEdgePercent(detail);
-}
-
-function isTradableMarketOpportunity(detail?: CityDetail | null) {
- const marketScan = detail?.market_scan;
- if (!marketScan) return false;
- if (marketScan.available === false) return false;
- if (marketScan.primary_market?.closed === true) return false;
- if (marketScan.primary_market?.active === false) return false;
-
- const selectedDate = marketScan.selected_date;
- const localDate = detail?.local_date;
- if (selectedDate && localDate && selectedDate < localDate) return false;
-
- const endDateMs = marketScan.primary_market?.end_date
- ? Date.parse(marketScan.primary_market.end_date)
- : Number.NaN;
- if (Number.isFinite(endDateMs) && endDateMs < Date.now()) return false;
-
- const marketBucket =
- marketScan.temperature_bucket || marketScan.top_buckets?.[0] || null;
- const yesPrice =
- marketScan.yes_buy ??
- readNumericField(marketBucket, "yes_buy") ??
- marketScan.yes_token?.buy_price ??
- marketScan.yes_token?.midpoint ??
- marketScan.price_analysis?.yes?.ask;
- const noPrice =
- marketScan.no_buy ??
- readNumericField(marketBucket, "no_buy") ??
- marketScan.no_token?.buy_price ??
- marketScan.no_token?.midpoint ??
- marketScan.price_analysis?.no?.ask;
- const marketPrice =
- marketScan.market_price ??
- readNumericField(marketBucket, "market_price") ??
- yesPrice;
-
- if (isBoundaryMarketPrice(yesPrice) || isBoundaryMarketPrice(noPrice)) {
- return false;
- }
- if (isBoundaryMarketPrice(marketPrice)) return false;
- const executableEdge = getExecutableEdgePercent(detail);
- return executableEdge != null && executableEdge > 0;
-}
-
-function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
- const store = useDashboardStore();
- const { locale } = useI18n();
- const spotlight = useMemo(
- () =>
- store.selectedCity
- ? snapshots.find(
- (snapshot) => snapshot.city.name === store.selectedCity,
- ) || null
- : null,
- [snapshots, store.selectedCity],
- );
- const spotlightView = useMemo(() => {
- if (!spotlight) return null;
-
- const { city, detail, summary } = spotlight;
- const symbol = getTempSymbol(city, summary, detail);
- const currentTemp = summary?.current?.temp ?? detail?.current?.temp;
- const debPrediction = summary?.deb?.prediction ?? detail?.deb?.prediction;
- const maxSoFar =
- detail?.current?.max_so_far ?? detail?.airport_current?.max_so_far;
- const maxTime =
- detail?.current?.max_temp_time ||
- detail?.airport_current?.max_temp_time ||
- "--";
- const localTime = summary?.local_time || detail?.local_time || "--";
- const riskLevel =
- city.deb_recent_tier ||
- city.risk_level ||
- summary?.risk?.level ||
- detail?.risk?.level;
- const probabilityBuckets =
- detail?.probabilities?.distribution ||
- (detail?.local_date
- ? detail?.multi_model_daily?.[detail.local_date]?.probabilities
- : undefined) ||
- [];
- const marketScan = detail?.market_scan;
- const displayedProbabilities = buildMarketAlignedProbabilities(
- marketScan,
- probabilityBuckets,
- symbol,
- );
- const marketBucket =
- marketScan?.temperature_bucket || marketScan?.top_buckets?.[0] || null;
- const yesPrice =
- marketScan?.yes_buy ??
- readNumericField(marketBucket, "yes_buy") ??
- marketScan?.yes_token?.buy_price ??
- marketScan?.yes_token?.midpoint ??
- marketScan?.price_analysis?.yes?.ask;
- const noPrice =
- marketScan?.no_buy ??
- readNumericField(marketBucket, "no_buy") ??
- marketScan?.no_token?.buy_price ??
- marketScan?.no_token?.midpoint ??
- marketScan?.price_analysis?.no?.ask;
- const marketEdge =
- marketScan?.edge_percent ??
- marketScan?.price_analysis?.yes?.edge_percent ??
- marketScan?.price_analysis?.yes?.edge ??
- marketScan?.price_analysis?.no?.edge_percent ??
- marketScan?.price_analysis?.no?.edge;
-
- return {
- city,
- detail,
- tradableOpportunity: spotlight.tradableOpportunity,
- symbol,
- currentTemp,
- debPrediction,
- maxSoFar,
- maxTime,
- localTime,
- riskLevel,
- displayedProbabilities,
- marketScan,
- showOpportunityLabel: !marketScan || spotlight.tradableOpportunity,
- marketLabel: marketBucket
- ? getProbabilityLabel(marketBucket, symbol)
- : debPrediction != null
- ? `> ${formatTemperature(debPrediction, symbol)}`
- : "--",
- yesPrice,
- noPrice,
- marketEdge,
- marketProbability:
- marketScan?.market_price ??
- readNumericField(marketBucket, "market_price") ??
- readNumericField(marketBucket, "yes_buy") ??
- yesPrice ??
- marketScan?.model_probability,
- marketModelProbability:
- marketScan?.model_probability ?? marketBucket?.probability,
- sparklinePoints: buildSparklinePoints(marketScan?.sparkline),
- isLoading:
- store.loadingState.cityDetail && store.selectedCity === city.name,
- isPro: store.proAccess.subscriptionActive,
- cityCode: city.icao || detail?.risk?.icao || city.airport,
- localizedCityName: getLocalizedCityDisplay(city, locale, summary, detail),
- localizedAirportName: getLocalizedAirportDisplay(city, locale, detail),
- highRiskLabel:
- riskLevel === "high"
- ? locale === "en-US"
- ? "High risk"
- : "高风险"
- : getRiskCopy(riskLevel, locale),
- weatherIconKind: getHomeWeatherIconKind(detail, locale),
- trendChart: buildHomeTrendChart(detail, locale),
- debLabel: locale === "en-US" ? "DEB forecast" : "DEB 预测",
- dayMaxLabel: locale === "en-US" ? "24h max" : "24 小时最高",
- probabilityTitle: locale === "en-US" ? "EMOS probability" : "EMOS 概率",
- marketTitle: locale === "en-US" ? "Market edge" : "市场优势",
- marketEdgeLabel: locale === "en-US" ? "Edge" : "优势",
- marketImpliedLabel: locale === "en-US" ? "Implied" : "市场隐含",
- marketModelLabel: locale === "en-US" ? "Model prob" : "模型概率",
- proLabel: store.proAccess.subscriptionActive
- ? locale === "en-US"
- ? "Pro signal"
- : "PRO 信号"
- : locale === "en-US"
- ? "Pro locked"
- : "PRO 锁定",
- forecastDays: buildHomeForecastDays(detail, locale),
- };
- }, [
- locale,
- spotlight,
- store.loadingState.cityDetail,
- store.proAccess.subscriptionActive,
- store.selectedCity,
- ]);
-
- if (!spotlightView) return null;
-
- const {
- city,
- detail,
- symbol,
- currentTemp,
- debPrediction,
- maxSoFar,
- maxTime,
- localTime,
- riskLevel,
- displayedProbabilities,
- marketScan,
- tradableOpportunity,
- showOpportunityLabel,
- marketLabel,
- yesPrice,
- noPrice,
- marketEdge,
- marketProbability,
- marketModelProbability,
- sparklinePoints,
- isLoading,
- isPro,
- cityCode,
- localizedCityName,
- localizedAirportName,
- highRiskLabel,
- weatherIconKind,
- trendChart,
- debLabel,
- dayMaxLabel,
- probabilityTitle,
- marketTitle,
- marketEdgeLabel,
- marketImpliedLabel,
- marketModelLabel,
- proLabel,
- forecastDays,
- } = spotlightView;
- const subtitle = `${cityCode} · ${localizedAirportName}`;
- const proCard = (
-
-
- {proLabel}
- {!isPro ? (
-
- {locale === "en-US"
- ? "History review and future dates stay paid."
- : "历史复盘和未来日期保持付费。"}
-
- ) : null}
-
- {isPro ? (
-
- ) : (
-
{locale === "en-US" ? "Upgrade" : "升级"}
- )}
-
- );
-
- return (
-
- );
-}
-
-function OpportunityStrip({
- snapshots,
- marketScanStatusByCity,
- scanTargetNames,
-}: {
- snapshots: CitySnapshot[];
- marketScanStatusByCity: Record;
- scanTargetNames: string[];
-}) {
- const { locale } = useI18n();
- const store = useDashboardStore();
- const stripState = useMemo(() => {
- const targetSet = new Set(scanTargetNames);
- const totalCount = scanTargetNames.length || snapshots.length;
- const targetedSnapshots = (
- scanTargetNames.length
- ? snapshots.filter((snapshot) => targetSet.has(snapshot.city.name))
- : snapshots
- ).slice(0, Math.max(scanTargetNames.length, 1));
- const effectiveStatusByCity = Object.fromEntries(
- targetedSnapshots.map((snapshot) => [
- snapshot.city.name,
- marketScanStatusByCity[snapshot.city.name] ||
- (snapshot.detail?.market_scan ? "complete" : undefined),
- ]),
- ) as Record;
- const completedCount = scanTargetNames.filter((cityName) => {
- const status = effectiveStatusByCity[cityName];
- return status === "complete" || status === "empty";
- }).length;
- const pendingCount = scanTargetNames.filter(
- (cityName) => effectiveStatusByCity[cityName] === "pending",
- ).length;
- const errorCount = scanTargetNames.filter(
- (cityName) => effectiveStatusByCity[cityName] === "error",
- ).length;
- const liveSnapshots = targetedSnapshots.filter((snapshot) =>
- isLiveMarketScan(snapshot.detail),
- );
- const tradableSnapshots = targetedSnapshots
- .filter((snapshot) => snapshot.tradableOpportunity)
- .sort((left, right) => {
- const leftEdge = normalizeEdgePercent(getMarketEdgeValue(left.detail));
- const rightEdge = normalizeEdgePercent(
- getMarketEdgeValue(right.detail),
- );
- return (
- Number(rightEdge ?? Number.NEGATIVE_INFINITY) -
- Number(leftEdge ?? Number.NEGATIVE_INFINITY) ||
- right.score - left.score
- );
- });
- const yesCount = liveSnapshots.filter(
- (snapshot) => getBestSide(snapshot.detail) === "yes",
- ).length;
- const noCount = liveSnapshots.filter(
- (snapshot) => getBestSide(snapshot.detail) === "no",
- ).length;
- const avgTradableEdge = tradableSnapshots.length
- ? tradableSnapshots.reduce((sum, snapshot) => {
- const edge = normalizeEdgePercent(
- getMarketEdgeValue(snapshot.detail),
- );
- return sum + (edge ?? 0);
- }, 0) / tradableSnapshots.length
- : null;
- const bestTradableSnapshot = tradableSnapshots[0] || null;
- const bestTradableEdge = bestTradableSnapshot
- ? normalizeEdgePercent(getMarketEdgeValue(bestTradableSnapshot.detail))
- : null;
- const bestTradableCityName = bestTradableSnapshot
- ? getLocalizedCityDisplay(
- bestTradableSnapshot.city,
- locale,
- bestTradableSnapshot.summary,
- bestTradableSnapshot.detail,
- )
- : "--";
- const quoteReadyCount = liveSnapshots.filter((snapshot) => {
- const marketScan = snapshot.detail?.market_scan;
- return Boolean(
- marketScan?.price_analysis?.available ||
- marketScan?.yes_buy != null ||
- marketScan?.no_buy != null,
- );
- }).length;
- const noEdgeCount = Math.max(quoteReadyCount - tradableSnapshots.length, 0);
-
- return {
- items: tradableSnapshots.slice(0, 5),
- totalCount,
- completedCount,
- pendingCount,
- errorCount,
- liveCount: liveSnapshots.length,
- tradableCount: tradableSnapshots.length,
- noEdgeCount,
- yesCount,
- noCount,
- avgTradableEdge,
- bestTradableSnapshot,
- bestTradableEdge,
- bestTradableCityName,
- summaryCards: [
- {
- key: "data-chain",
- title: locale === "en-US" ? "Data Chain" : "数据链路",
- items: [
- {
- label: locale === "en-US" ? "Markets" : "市场列表",
- value: "Gamma",
- accent: "cyan" as const,
- },
- {
- label: locale === "en-US" ? "Quotes" : "盘口价格",
- value: "CLOB",
- accent: "green" as const,
- },
- {
- label: locale === "en-US" ? "Model" : "概率模型",
- value: "EMOS",
- accent: "amber" as const,
- },
- ],
- },
- {
- key: "refresh-cadence",
- title: locale === "en-US" ? "Refresh Cadence" : "刷新节奏",
- items: [
- {
- label: locale === "en-US" ? "Market list" : "市场列表",
- value: "60s",
- accent: "green" as const,
- },
- {
- label: locale === "en-US" ? "YES/NO ask" : "YES/NO 买价",
- value: "30s",
- accent: "cyan" as const,
- },
- {
- label: locale === "en-US" ? "Mode" : "模式",
- value: "REST",
- },
- ],
- },
- {
- key: "quote-filter",
- title: locale === "en-US" ? "Quote Filter" : "报价筛选",
- items: [
- {
- label: locale === "en-US" ? "Live" : "在线",
- value: String(liveSnapshots.length),
- accent: "green" as const,
- },
- {
- label: locale === "en-US" ? "Quoted" : "已报价",
- value: String(quoteReadyCount),
- accent: "cyan" as const,
- },
- {
- label: locale === "en-US" ? "No edge" : "无正 edge",
- value: String(noEdgeCount),
- accent: "amber" as const,
- },
- ],
- },
- {
- key: "edge-decision",
- title: locale === "en-US" ? "Decision" : "交易决策",
- items: [
- {
- label: locale === "en-US" ? "Actionable" : "可买",
- value: String(tradableSnapshots.length),
- accent: "green" as const,
- },
- {
- label: locale === "en-US" ? "Avg edge" : "平均 edge",
- value: formatEdge(avgTradableEdge),
- accent: avgTradableEdge != null ? ("cyan" as const) : undefined,
- },
- {
- label: locale === "en-US" ? "Focus" : "当前标的",
- value: bestTradableCityName,
- accent: "amber" as const,
- },
- ],
- },
- ],
- overviewKicker:
- locale === "en-US"
- ? "EMOS / CLOB Decision Layer"
- : "EMOS / CLOB 交易决策层",
- headingTitle:
- tradableSnapshots.length > 0
- ? locale === "en-US"
- ? `${getTradeSideLabel(getBestSide(bestTradableSnapshot?.detail), locale)} · ${bestTradableCityName} · ${formatEdge(bestTradableEdge)}`
- : `${getTradeSideLabel(getBestSide(bestTradableSnapshot?.detail), locale)} · ${bestTradableCityName} · ${formatEdge(bestTradableEdge)}`
- : pendingCount > 0
- ? locale === "en-US"
- ? "Waiting for executable CLOB quotes"
- : "等待可执行 CLOB 报价"
- : locale === "en-US"
- ? "No positive EMOS edge right now"
- : "当前没有正 EMOS edge",
- overviewBody:
- tradableSnapshots.length > 0
- ? locale === "en-US"
- ? "Decision rule: compare the EMOS probability for the exact market bucket with the executable YES/NO ask from CLOB REST."
- : "决策规则:把具体温度桶的 EMOS 概率和 CLOB REST 可买 YES/NO 价格相减,只展示正 edge 的买入方向。"
- : pendingCount > 0
- ? locale === "en-US"
- ? "Gamma active markets are cached separately; the faster loop waits for CLOB YES/NO asks and recalculates edge locally."
- : "Gamma 活跃市场单独缓存;更快的价格层等待 CLOB YES/NO 买价后在本地重算 edge。"
- : locale === "en-US"
- ? "Markets can still be live, but the current asks are not cheaper than the EMOS distribution."
- : "盘口可以在线,但当前 ask 没有低于 EMOS 概率分布,先不提示买入。",
- tapeStats: [
- {
- key: "coverage",
- label: locale === "en-US" ? "Gamma" : "Gamma 市场",
- value: "60s",
- accent: "cyan" as const,
- },
- {
- key: "live",
- label: locale === "en-US" ? "CLOB asks" : "CLOB 报价",
- value: "30s",
- accent: "green" as const,
- },
- {
- key: "tradable",
- label: locale === "en-US" ? "Actionable" : "可买机会",
- value: String(tradableSnapshots.length),
- accent: "cyan" as const,
- },
- {
- key: "avg-edge",
- label: locale === "en-US" ? "Best edge" : "最佳 edge",
- value: formatEdge(bestTradableEdge),
- accent:
- bestTradableEdge != null ? ("green" as const) : ("slate" as const),
- },
- ],
- yesCountLabel:
- locale === "en-US" ? `BUY YES ${yesCount}` : `买 YES ${yesCount}`,
- noCountLabel:
- locale === "en-US" ? `BUY NO ${noCount}` : `买 NO ${noCount}`,
- };
- }, [locale, marketScanStatusByCity, scanTargetNames, snapshots]);
-
- if (!snapshots.length) return null;
-
- const heroSnapshot = stripState.bestTradableSnapshot;
- const heroSide = getBestSide(heroSnapshot?.detail);
- const heroSignalLabel = getTradeSideLabel(heroSide, locale);
- const heroSignalTone = heroSignalLabel.toLowerCase().includes("yes")
- ? "yes"
- : heroSignalLabel.toLowerCase().includes("no")
- ? "no"
- : "neutral";
- const heroBestAsk = getBestSideAsk(heroSnapshot?.detail);
- const heroBestProbability = getBestSideModelProbability(heroSnapshot?.detail);
- const heroBestEdge = getExecutableEdgePercent(heroSnapshot?.detail);
- const heroSymbol = heroSnapshot
- ? getTempSymbol(
- heroSnapshot.city,
- heroSnapshot.summary,
- heroSnapshot.detail,
- )
- : "°C";
- const heroTier =
- heroSnapshot?.city.deb_recent_tier ||
- heroSnapshot?.city.risk_level ||
- heroSnapshot?.summary?.risk?.level ||
- heroSnapshot?.detail?.risk?.level;
- const heroCityName = heroSnapshot
- ? getLocalizedCityDisplay(
- heroSnapshot.city,
- locale,
- heroSnapshot.summary,
- heroSnapshot.detail,
- )
- : "--";
- const heroMarketBucket =
- heroSnapshot?.detail?.market_scan?.temperature_bucket;
- const heroQuestion =
- heroSnapshot?.detail?.market_scan?.primary_market?.question ||
- `${getProbabilityLabel(heroMarketBucket || {}, heroSymbol)} ${
- heroSnapshot?.detail?.market_scan?.selected_date ||
- heroSnapshot?.detail?.local_date ||
- ""
- }`;
- const heroEngine = (() => {
- const engine = String(
- heroSnapshot?.detail?.market_scan?.probability_engine || "EMOS",
- ).toLowerCase();
- if (engine.includes("emos")) return "EMOS";
- return engine.toUpperCase();
- })();
- const heroSpread = heroSnapshot?.detail?.market_scan?.spread ?? null;
- const heroQuoteSource = heroSnapshot?.detail?.market_scan?.quote_source
- ? String(heroSnapshot.detail.market_scan.quote_source)
- .replaceAll("_", " ")
- .toUpperCase()
- : "CLOB REST";
- const heroDateLabel =
- heroSnapshot?.detail?.market_scan?.selected_date ||
- heroSnapshot?.detail?.local_date ||
- "--";
- const heroSparkline = buildSparklinePoints(
- heroSnapshot?.detail?.market_scan?.sparkline?.length
- ? heroSnapshot.detail.market_scan.sparkline
- : [
- Number(
- heroSnapshot?.summary?.current?.temp ??
- heroSnapshot?.detail?.current?.temp ??
- 0,
- ),
- Number(
- heroSnapshot?.summary?.deb?.prediction ??
- heroSnapshot?.detail?.deb?.prediction ??
- 0,
- ),
- Number(
- heroSnapshot?.detail?.forecast?.today_high ??
- heroSnapshot?.summary?.deb?.prediction ??
- 0,
- ),
- ],
- );
- const secondaryItems = heroSnapshot
- ? stripState.items.filter(
- (snapshot) => snapshot.city.name !== heroSnapshot.city.name,
- )
- : [];
-
- return (
-
-
-
-
- {stripState.overviewKicker}
-
-
{stripState.headingTitle}
-
{stripState.overviewBody}
-
-
- {stripState.tapeStats.map((item) => (
-
- {item.label}
- {item.value}
-
- ))}
-
- {stripState.yesCountLabel}
-
-
- {stripState.noCountLabel}
-
-
-
- {heroSnapshot ? (
- <>
-
-
-
-
- {stripState.summaryCards.map((card) => (
-
-
- {card.title}
-
-
- {card.items.map((item) => (
-
-
- {item.value}
-
- {item.label}
-
- ))}
-
-
- ))}
-
-
-
- {secondaryItems.length > 0 ? (
-
- {secondaryItems.map(({ city, detail, summary }, index) => {
- const symbol = getTempSymbol(city, summary, detail);
- const debPrediction =
- summary?.deb?.prediction ?? detail?.deb?.prediction;
- const localizedCityName = getLocalizedCityDisplay(
- city,
- locale,
- summary,
- detail,
- );
- const tier =
- city.deb_recent_tier ||
- city.risk_level ||
- summary?.risk?.level ||
- detail?.risk?.level;
- const marketBucket = detail?.market_scan?.temperature_bucket;
- const marketQuestion =
- detail?.market_scan?.primary_market?.question ||
- `${getProbabilityLabel(marketBucket || {}, symbol)} ${
- detail?.market_scan?.selected_date ||
- detail?.local_date ||
- ""
- }`;
- const opportunitySparkline = buildSparklinePoints(
- detail?.market_scan?.sparkline?.length
- ? detail.market_scan.sparkline
- : [
- Number(
- summary?.current?.temp ?? detail?.current?.temp ?? 0,
- ),
- Number(debPrediction ?? 0),
- Number(
- detail?.forecast?.today_high ?? debPrediction ?? 0,
- ),
- ],
- );
- return (
-
- );
- })}
-
- ) : null}
- >
- ) : (
-
-
-
- {stripState.pendingCount > 0
- ? locale === "en-US"
- ? "Scanning current market layers..."
- : "正在扫描当前市场层..."
- : locale === "en-US"
- ? "No tradable market at the moment"
- : "当前没有满足条件的可交易市场"}
-
-
- {locale === "en-US"
- ? `Completed ${stripState.completedCount}/${stripState.totalCount} cities, live ${stripState.liveCount}, tradable ${stripState.tradableCount}.`
- : `已完成 ${stripState.completedCount}/${stripState.totalCount} 个城市扫描,在线盘口 ${stripState.liveCount} 个,可交易机会 ${stripState.tradableCount} 个。`}
-
-
-
- )}
-
- );
-}
-
-function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
- const store = useDashboardStore();
- const { locale } = useI18n();
- const [isOpen, setIsOpen] = useState(false);
- const [showPaywall, setShowPaywall] = useState(false);
- const [loading, setLoading] = useState(false);
- const [input, setInput] = useState("");
- const [messages, setMessages] = useState([]);
- const [error, setError] = useState(null);
- const [isDragging, setIsDragging] = useState(false);
- const [dockPosition, setDockPosition] =
- useState(null);
- const dockRef = useRef(null);
- const dockPositionRef = useRef(null);
- const dragStateRef = useRef(null);
- const suppressLauncherClickRef = useRef(false);
- const messagesEndRef = useRef(null);
-
- const selectedSnapshot = useMemo(
- () =>
- store.selectedCity
- ? snapshots.find(
- (snapshot) => snapshot.city.name === store.selectedCity,
- ) || null
- : null,
- [snapshots, store.selectedCity],
- );
- const selectedCityName = selectedSnapshot
- ? getLocalizedCityDisplay(
- selectedSnapshot.city,
- locale,
- selectedSnapshot.summary,
- selectedSnapshot.detail,
- )
- : null;
- const assistantContext = useMemo(
- () => buildAssistantContextPayload(snapshots, store.selectedCity, locale),
- [locale, snapshots, store.selectedCity],
- );
- const starterPrompts = useMemo(() => {
- if (locale === "en-US") {
- return [
- selectedCityName
- ? `What is today's forecast high for ${selectedCityName}?`
- : "What is today's forecast high for the focus city?",
- "Which market is worth buying now?",
- "Rank current opportunities by edge",
- ];
- }
- return [
- selectedCityName
- ? `${selectedCityName} 今天预测最高温是多少?`
- : "当前焦点城市今天预测最高温是多少?",
- "当前有哪些值得参与的市场?",
- "按 edge 排序",
- ];
- }, [locale, selectedCityName]);
-
- useEffect(() => {
- if (messages.length) return;
- setMessages([
- {
- id: "assistant-greeting",
- role: "assistant",
- content: buildAssistantGreeting(locale, selectedCityName),
- },
- ]);
- }, [locale, messages.length, selectedCityName]);
-
- useEffect(() => {
- messagesEndRef.current?.scrollIntoView({
- behavior: loading ? "auto" : "smooth",
- block: "end",
- });
- }, [loading, messages]);
-
- useEffect(() => {
- dockPositionRef.current = dockPosition;
- }, [dockPosition]);
-
- useEffect(() => {
- if (typeof window === "undefined") return;
-
- const syncDockPosition = () => {
- if (window.innerWidth <= 960) {
- setDockPosition(null);
- return;
- }
- setDockPosition((current) =>
- clampAssistantDockPosition(
- current ||
- readAssistantDockPosition() ||
- getDefaultAssistantDockPosition(),
- dockRef.current,
- ),
- );
- };
-
- syncDockPosition();
- window.addEventListener("resize", syncDockPosition);
- return () => {
- window.removeEventListener("resize", syncDockPosition);
- };
- }, [isOpen]);
-
- const openAssistant = () => {
- if (suppressLauncherClickRef.current) {
- suppressLauncherClickRef.current = false;
- return;
- }
- if (!store.proAccess.subscriptionActive) {
- setShowPaywall(true);
- return;
- }
- setIsOpen(true);
- };
-
- const beginDockDrag = (event: ReactPointerEvent) => {
- if (typeof window === "undefined" || window.innerWidth <= 960) return;
- const basePosition = clampAssistantDockPosition(
- dockPositionRef.current ||
- readAssistantDockPosition() ||
- getDefaultAssistantDockPosition(),
- dockRef.current,
- );
- if (!basePosition) return;
- event.preventDefault();
- event.stopPropagation();
- dragStateRef.current = {
- pointerId: event.pointerId,
- startX: event.clientX,
- startY: event.clientY,
- right: basePosition.right,
- bottom: basePosition.bottom,
- hasMoved: false,
- };
- event.currentTarget.setPointerCapture?.(event.pointerId);
- dockPositionRef.current = basePosition;
- setDockPosition(basePosition);
- setIsDragging(true);
- };
-
- const updateDockDrag = (event: ReactPointerEvent) => {
- const dragState = dragStateRef.current;
- if (!dragState || dragState.pointerId !== event.pointerId) return;
- event.preventDefault();
- event.stopPropagation();
- const deltaX = event.clientX - dragState.startX;
- const deltaY = event.clientY - dragState.startY;
- if (!dragState.hasMoved && Math.abs(deltaX) + Math.abs(deltaY) >= 6) {
- dragState.hasMoved = true;
- }
- const nextPosition = clampAssistantDockPosition(
- {
- right: dragState.right - deltaX,
- bottom: dragState.bottom - deltaY,
- },
- dockRef.current,
- );
- if (!nextPosition) return;
- dockPositionRef.current = nextPosition;
- setDockPosition(nextPosition);
- };
-
- const endDockDrag = (event: ReactPointerEvent) => {
- const dragState = dragStateRef.current;
- if (!dragState || dragState.pointerId !== event.pointerId) return;
- event.preventDefault();
- event.stopPropagation();
- try {
- if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
- event.currentTarget.releasePointerCapture(event.pointerId);
- }
- } catch {
- // Pointer capture may already be released by the browser.
- }
- const finalPosition = clampAssistantDockPosition(
- dockPositionRef.current || {
- right: dragState.right,
- bottom: dragState.bottom,
- },
- dockRef.current,
- );
- if (dragState.hasMoved) {
- suppressLauncherClickRef.current = true;
- }
- dockPositionRef.current = finalPosition;
- setDockPosition(finalPosition);
- writeAssistantDockPosition(finalPosition);
- dragStateRef.current = null;
- setIsDragging(false);
- };
-
- const sendQuestion = async (rawQuestion?: string) => {
- const question = String(rawQuestion ?? input).trim();
- if (!question || loading) return;
- if (!store.proAccess.subscriptionActive) {
- setShowPaywall(true);
- return;
- }
-
- setIsOpen(true);
- setError(null);
- setLoading(true);
- setMessages((current) => [
- ...current,
- {
- id: `user-${Date.now()}`,
- role: "user",
- content: question,
- },
- ]);
- setInput("");
-
- try {
- const reply = await dashboardClient.askAssistant({
- question,
- locale,
- snapshotId: assistantContext.snapshot_id,
- context: assistantContext,
- });
- setMessages((current) => [
- ...current,
- {
- id: `assistant-${Date.now()}`,
- role: "assistant",
- content: reply.answer,
- cached: reply.cached,
- },
- ]);
- } catch (submitError) {
- const message = String(submitError);
- if (message.includes("HTTP 402")) {
- setShowPaywall(true);
- } else {
- setError(
- locale === "en-US"
- ? "Assistant is temporarily unavailable."
- : "AI 助手暂时不可用。",
- );
- }
- } finally {
- setLoading(false);
- }
- };
-
- const submitForm = async (event: FormEvent) => {
- event.preventDefault();
- await sendQuestion();
- };
-
- return (
- <>
-
- {!isOpen ? (
-
- ) : (
-
-
-
-
- {locale === "en-US" ? "AI assistant" : "AI 对话助手"}
-
-
- {locale === "en-US"
- ? "Ask about cities, forecast highs, edge, and live opportunities"
- : "可直接问城市、最高温预测、edge 和实时市场机会"}
-
-
-
-
- ⋮⋮
-
-
-
-
-
-
- {locale === "en-US"
- ? "You can ask about current temperature, today's forecast high, market opportunities, edge, and risk reasons."
- : "可直接问当前温度、今日最高温、市场机会、edge 和风险原因。"}
-
-
-
- {messages.map((message) => (
-
-
{message.content}
- {message.cached ? (
-
- {locale === "en-US" ? "Cache hit" : "命中缓存"}
-
- ) : null}
-
- ))}
- {loading ? (
-
-
- {locale === "en-US" ? "Thinking..." : "正在整理答案..."}
-
-
- ) : null}
-
-
-
-
- {starterPrompts.map((prompt) => (
-
- ))}
-
-
-
-
- )}
-
-
- {showPaywall ? (
- setShowPaywall(false)}
- >
-
event.stopPropagation()}
- >
-
setShowPaywall(false)}
- />
-
-
- ) : null}
- >
- );
-}
-
-function DashboardScreen() {
- const store = useDashboardStore();
- const { t } = useI18n();
- const didAutoFocusRef = useRef(false);
- const marketScanInflightRef = useRef>(new Set());
- const marketScanPollingRef = useRef(false);
- const [marketScanStatusByCity, setMarketScanStatusByCity] = useState<
- Record
- >({});
- const activeSummary = store.selectedCity
- ? store.citySummariesByName[store.selectedCity] || null
- : null;
- const activeCityName =
- store.selectedDetail?.display_name ||
- activeSummary?.display_name ||
- store.cities.find((city) => city.name === store.selectedCity)
- ?.display_name ||
- store.selectedCity ||
- "";
-
- useEffect(() => {
- const onKeyDown = (event: KeyboardEvent) => {
- if (event.key !== "Escape") return;
- if (store.futureModalDate) {
- store.closeFutureModal();
- return;
- }
- if (store.historyState.isOpen) {
- store.closeHistory();
- return;
- }
- if (store.isPanelOpen) {
- store.closePanel();
- }
- };
-
- window.addEventListener("keydown", onKeyDown);
- return () => {
- window.removeEventListener("keydown", onKeyDown);
- };
- }, [store]);
-
- // Avoid full-page flashing on initial load; only show this overlay for manual refresh.
- const showLoading = store.loadingState.cities || store.loadingState.refresh;
- const showCitySyncToast =
- store.loadingState.cityDetail &&
- activeCityName &&
- !store.selectedDetail &&
- !activeSummary;
- const homepageSnapshots = useMemo(
- () =>
- store.cities
- .map((city) =>
- buildSnapshot(
- city,
- store.citySummariesByName[city.name] || null,
- store.cityDetailsByName[city.name] || null,
- ),
- )
- .sort(
- (a, b) =>
- b.score - a.score ||
- a.city.display_name.localeCompare(b.city.display_name),
- ),
- [store.cities, store.cityDetailsByName, store.citySummariesByName],
- );
- const marketScanTargetNames = useMemo(
- () => homepageSnapshots.map((snapshot) => snapshot.city.name),
- [homepageSnapshots],
- );
- const showHomepageChrome =
- !store.historyState.isOpen && !store.futureModalDate;
-
- useEffect(() => {
- if (didAutoFocusRef.current) return;
- if (!showHomepageChrome) return;
- if (store.selectedCity) return;
- const topOpportunity = homepageSnapshots[0]?.city.name;
- if (!topOpportunity) return;
-
- didAutoFocusRef.current = true;
- void store.focusCity(topOpportunity);
- }, [homepageSnapshots, showHomepageChrome, store]);
-
- useEffect(() => {
- if (store.loadingState.refresh) {
- marketScanInflightRef.current.clear();
- setMarketScanStatusByCity({});
- }
- }, [store.loadingState.refresh]);
-
- useEffect(() => {
- if (store.proAccess.loading) return;
- if (store.proAccess.authenticated) return;
- marketScanInflightRef.current.clear();
- setMarketScanStatusByCity({});
- }, [store.proAccess.authenticated, store.proAccess.loading]);
-
- useEffect(() => {
- if (!showHomepageChrome) return;
- if (store.proAccess.loading || !store.proAccess.authenticated) return;
- if (!marketScanTargetNames.length) return;
-
- let cancelled = false;
- const queue = marketScanTargetNames.filter((cityName) => {
- const status = marketScanStatusByCity[cityName];
- const existingMarketScan = store.cityDetailsByName[cityName]?.market_scan;
- return (
- !status &&
- !existingMarketScan &&
- !marketScanInflightRef.current.has(cityName)
- );
- });
- if (!queue.length) return;
-
- const runWorker = async () => {
- while (!cancelled) {
- const cityName = queue.shift();
- if (!cityName) return;
- marketScanInflightRef.current.add(cityName);
- setMarketScanStatusByCity((current) => ({
- ...current,
- [cityName]: "pending",
- }));
- try {
- const existingDetail = store.cityDetailsByName[cityName];
- const marketScan =
- existingDetail?.market_scan ||
- (await store.ensureCityMarketScan(cityName, false, { lite: true }));
- if (cancelled) return;
- setMarketScanStatusByCity((current) => ({
- ...current,
- [cityName]: marketScan ? "complete" : "empty",
- }));
- } catch {
- if (cancelled) return;
- setMarketScanStatusByCity((current) => ({
- ...current,
- [cityName]: "error",
- }));
- } finally {
- marketScanInflightRef.current.delete(cityName);
- }
- }
- };
-
- void Promise.allSettled(
- Array.from({ length: Math.min(2, queue.length) }, () => runWorker()),
- );
-
- return () => {
- cancelled = true;
- };
- }, [
- marketScanStatusByCity,
- marketScanTargetNames,
- showHomepageChrome,
- store.cityDetailsByName,
- store.ensureCityMarketScan,
- store.proAccess.authenticated,
- store.proAccess.loading,
- ]);
-
- useEffect(() => {
- if (!showHomepageChrome) return;
- if (store.proAccess.loading || !store.proAccess.authenticated) return;
- if (!marketScanTargetNames.length) return;
-
- let cancelled = false;
- let intervalId: number | null = null;
-
- const refreshAllMarketScans = async () => {
- if (cancelled || marketScanPollingRef.current) return;
- marketScanPollingRef.current = true;
- const queue = [...marketScanTargetNames];
- try {
- const runWorker = async () => {
- while (!cancelled) {
- const cityName = queue.shift();
- if (!cityName) return;
- await store.ensureCityMarketScan(cityName, false, { lite: true });
- }
- };
- await Promise.allSettled(
- Array.from({ length: Math.min(3, queue.length) }, () => runWorker()),
- );
- } finally {
- marketScanPollingRef.current = false;
- }
- };
-
- void refreshAllMarketScans();
- intervalId = window.setInterval(() => {
- if (
- typeof document !== "undefined" &&
- document.visibilityState === "hidden"
- ) {
- return;
- }
- void refreshAllMarketScans();
- }, HOME_OPPORTUNITY_REFRESH_MS);
-
- return () => {
- cancelled = true;
- marketScanPollingRef.current = false;
- if (intervalId != null) {
- window.clearInterval(intervalId);
- }
- };
- }, [
- marketScanTargetNames,
- showHomepageChrome,
- store.ensureCityMarketScan,
- store.proAccess.authenticated,
- store.proAccess.loading,
- ]);
-
- return (
-
-
-
- {/* Left — Filter Panel */}
-
-
- {/* Center — Data Grid */}
-
-
-
-
- {t("dashboard.opportunityTitle") || "可交易机会"}
-
-
- {t("dashboard.opportunitySubtitle") ||
- "基于当前时间、实况数据和模型预测,筛选出最具交易价值的市场"}
-
-
-
-
-
s.tradableOpportunity,
- ).length,
- avgEdge: (() => {
- const tradable = homepageSnapshots.filter(
- (s) => s.tradableOpportunity,
- );
- if (!tradable.length) return null;
- const sum = tradable.reduce((acc, s) => {
- const edge = normalizeEdgePercent(
- getMarketEdgeValue(s.detail),
- );
- return acc + (edge ?? 0);
- }, 0);
- return sum / tradable.length;
- })(),
- totalWinRate: (() => {
- const tradable = homepageSnapshots.filter(
- (s) => s.tradableOpportunity,
- );
- if (!tradable.length) return null;
- const modelProbs = tradable
- .map((s) => s.detail?.market_scan?.model_probability)
- .filter((v): v is number => v != null);
- if (!modelProbs.length) return null;
- return (
- (modelProbs.reduce((a, b) => a + b, 0) / modelProbs.length) *
- 100
- );
- })(),
- tradableMarkets: homepageSnapshots.filter(
- (s) => s.tradableOpportunity,
- ).length,
- filteredTotal: homepageSnapshots.length,
- totalVolume: homepageSnapshots.reduce((sum, s) => {
- const vol =
- s.detail?.market_scan?.volume ??
- s.detail?.market_scan?.primary_market?.volume ??
- 0;
- return sum + vol;
- }, 0),
- }}
- />
-
-
-
-
-
-
-
- ({
- rank: index + 1,
- city: snapshot.city,
- summary: snapshot.summary,
- detail: snapshot.detail,
- score: snapshot.score,
- tradable: snapshot.tradableOpportunity,
- }))}
- />
-
-
- {/* Right — Scan Detail Panel */}
- {store.selectedDetail ? (
-
- ) : (
-
- )}
-
-
- {store.historyState.isOpen &&
}
- {store.futureModalDate &&
}
- {showLoading && (
-
-
-
-
-
-
-
-
- PolyWeather
- {t("dashboard.loading")}
-
-
-
- )}
-
- );
-}
+import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard";
export function PolyWeatherDashboard() {
- return (
-
-
-
-
-
- );
+ return ;
}
diff --git a/frontend/components/dashboard/ScanFilterPanel.tsx b/frontend/components/dashboard/ScanFilterPanel.tsx
index 118defff..79cbe298 100644
--- a/frontend/components/dashboard/ScanFilterPanel.tsx
+++ b/frontend/components/dashboard/ScanFilterPanel.tsx
@@ -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({
- mode: "tradable",
- priceRange: [0.05, 0.95],
- minEdge: 2,
- highLiquidityOnly: false,
- marketType: "maxtemp",
- timeRange: "today",
- });
-
const updateFilter = (
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({
{SCAN_MODES.map((mode) => {
const Icon = mode.icon;
- const isActive = filters.mode === mode.key;
+ const isActive = value.scan_mode === mode.key;
return (
@@ -161,13 +152,15 @@ export function ScanFilterPanel({
{isEn ? "Min Edge" : "最小边际优势"}
- {filters.minEdge}%
+ {value.min_edge_pct}%
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"
/>
@@ -179,10 +172,17 @@ export function ScanFilterPanel({
{isEn ? "High Liquidity Only" : "只看高流动性"}
@@ -195,11 +195,11 @@ export function ScanFilterPanel({