diff --git a/frontend/components/dashboard/scan-terminal/continent-grouping.ts b/frontend/components/dashboard/scan-terminal/continent-grouping.ts new file mode 100644 index 00000000..5acdb230 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/continent-grouping.ts @@ -0,0 +1,189 @@ +import type { ScanOpportunityRow } from "@/lib/dashboard-types"; + +export const TRADING_REGIONS = [ + { key: "east_asia", labelEn: "East Asia", labelZh: "东亚", sort: 1 }, + { key: "southeast_asia", labelEn: "Southeast Asia", labelZh: "东南亚", sort: 2 }, + { key: "central_asia", labelEn: "Central / South Asia", labelZh: "中亚 / 南亚", sort: 3 }, + { key: "west_asia", labelEn: "West Asia / Middle East", labelZh: "西亚 / 中东", sort: 4 }, + { key: "europe_africa", labelEn: "Europe / Africa", labelZh: "欧洲 / 非洲", sort: 5 }, + { key: "south_america", labelEn: "Latin America", labelZh: "拉美", sort: 6 }, + { key: "north_america", labelEn: "North America", labelZh: "北美", sort: 7 }, +] as const; + +export type TradingRegionKey = (typeof TRADING_REGIONS)[number]["key"]; + +export interface ContinentGroup { + key: string; + labelEn: string; + labelZh: string; + sort: number; + rows: ScanOpportunityRow[]; + activeCount: number; + watchCount: number; + hotCity: string | null; + localTimeRange: string | null; +} + +export function isActiveSignal(row: ScanOpportunityRow): boolean { + const decision = String(row.ai_decision || row.v4_metar_decision || "").toLowerCase(); + if (decision.includes("approve")) return true; + if (row.tradable && row.active) return true; + return false; +} + +export function isWatchSignal(row: ScanOpportunityRow): boolean { + const decision = String(row.ai_decision || row.v4_metar_decision || row.signal_status || "").toLowerCase(); + if (decision.includes("watch")) return true; + if (decision.includes("monitor")) return true; + if (!row.tradable && row.active) return true; + return false; +} + +export function isDeadSignal(row: ScanOpportunityRow): boolean { + if (row.closed) return true; + const decision = String(row.ai_decision || row.v4_metar_decision || "").toLowerCase(); + if (decision.includes("veto")) return true; + return false; +} + +export function getSignalState(row: ScanOpportunityRow): "active" | "watch" | "closed" | "data" { + if (isDeadSignal(row)) return "closed"; + if (isActiveSignal(row)) return "active"; + if (isWatchSignal(row)) return "watch"; + return "data"; +} + +export function getSignalLabel(state: ReturnType, isEn: boolean): string { + switch (state) { + case "active": return isEn ? "◆ Active" : "◆ 活跃"; + case "watch": return isEn ? "● Watch" : "● 观察"; + case "closed": return isEn ? "○ Closed" : "○ 关闭"; + case "data": return isEn ? "! Data" : "! 数据"; + } +} + +export type GapColor = "green" | "orange" | "slate" | "gray" | "red"; + +export function getGapColor(row: ScanOpportunityRow): GapColor { + const gap = Number(row.signed_gap ?? row.gap_to_target); + const edge = Number(row.edge_percent || 0); + const spread = Number(row.spread || 0); + const liq = Number(row.book_liquidity || row.market_liquidity || 0); + + if (!Number.isFinite(gap)) return "gray"; + if (liq <= 0 || spread > 20) return "red"; + if (gap >= 2) return "green"; + if (gap >= 0 && edge > 5) return "orange"; + if (gap >= 0) return "slate"; + if (gap < -5 || edge < -10) return "gray"; + return "slate"; +} + +export const GAP_COLOR_MAP: Record = { + green: "text-emerald-600", + orange: "text-amber-600", + slate: "text-slate-500", + gray: "text-slate-400", + red: "text-red-500", +}; + +export function formatPrice(midpoint?: number | null, ask?: number | null, bid?: number | null): string { + const m = Number(midpoint); + if (Number.isFinite(m) && m > 0) { + const cents = Math.round(m * 100); + return `Y ${cents}¢`; + } + const a = Number(ask); + if (Number.isFinite(a) && a > 0) { + const cents = Math.round(a * 100); + return `Y ${cents}¢`; + } + return "--"; +} + +export function formatSpreadLiquidity(spread?: number | null, liquidity?: number | null): string { + const sp = Number(spread); + const liq = Number(liquidity); + const spStr = Number.isFinite(sp) ? `${Math.round(sp)}¢` : "--"; + const liqStr = Number.isFinite(liq) + ? liq >= 1000 + ? `$${(liq / 1000).toFixed(1)}K` + : `$${Math.round(liq)}` + : "--"; + return `${spStr} / ${liqStr}`; +} + +export function buildContinentGroups(rows: ScanOpportunityRow[], isEn: boolean): ContinentGroup[] { + const regionMap = new Map(); + + for (const row of rows) { + const region = String(row.trading_region || "unknown").toLowerCase(); + if (!regionMap.has(region)) regionMap.set(region, []); + regionMap.get(region)!.push(row); + } + + const groups: ContinentGroup[] = []; + + // Active Signals virtual group + const activeRows = rows.filter((r) => isActiveSignal(r)); + if (activeRows.length > 0) { + const hotRow = activeRows.reduce((best, r) => + Number(r.edge_percent || 0) > Number(best.edge_percent || 0) ? r : best + ); + groups.push({ + key: "active_signals", + labelEn: "Active Signals", + labelZh: "活跃信号", + sort: 0, + rows: activeRows, + activeCount: activeRows.filter((r) => isActiveSignal(r)).length, + watchCount: activeRows.filter((r) => isWatchSignal(r)).length, + hotCity: hotRow?.city_display_name || hotRow?.city || null, + localTimeRange: null, + }); + } + + for (const region of TRADING_REGIONS) { + const regionRows = regionMap.get(region.key) || []; + if (regionRows.length === 0) continue; + + const activeCount = regionRows.filter((r) => isActiveSignal(r)).length; + const watchCount = regionRows.filter((r) => isWatchSignal(r)).length; + const sorted = [...regionRows].sort((a, b) => + Number(b.final_score || 0) - Number(a.final_score || 0) + ); + const hotCity = sorted[0]?.city_display_name || sorted[0]?.city || null; + + const times = regionRows.map((r) => String(r.local_time || "").trim()).filter(Boolean); + const ltRange = times.length >= 2 + ? `${times[0]}-${times[times.length - 1]}` + : times[0] || null; + + groups.push({ + key: region.key, + labelEn: region.labelEn, + labelZh: region.labelZh, + sort: region.sort, + rows: regionRows, + activeCount, + watchCount, + hotCity, + localTimeRange: ltRange, + }); + } + + groups.sort((a, b) => a.sort - b.sort); + return groups; +} + +export function getDefaultExpanded(groups: ContinentGroup[]): Set { + const expanded = new Set(); + for (const g of groups) { + if (g.key === "active_signals") { + expanded.add(g.key); + } else if (g.activeCount > 0 || g.watchCount > 0) { + expanded.add(g.key); + } + } + return expanded; +}