astock-agent/frontend/src/app/(auth)/dashboard/page.tsx
2026-05-14 17:02:13 +08:00

745 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchAPI, postAPI } from "@/lib/api";
import type {
IndexOverview,
LatestResult,
MarketTemperatureData,
OpsStatusResponse,
RecommendationData,
SectorData,
StrategyBoard,
} from "@/lib/api";
import { ThemeToggle } from "@/components/theme-toggle";
import { useAuth } from "@/hooks/use-auth";
import { useWebSocket } from "@/hooks/use-websocket";
interface ScanStatus {
is_trading: boolean;
scan_mode: string;
description: string;
}
const SCAN_TIMEOUT_MS = 120_000;
export default function DashboardPage() {
const { user } = useAuth();
const [data, setData] = useState<LatestResult | null>(null);
const [marketTemperature, setMarketTemperature] = useState<MarketTemperatureData | null>(null);
const [sectors, setSectors] = useState<SectorData[]>([]);
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [refreshResult, setRefreshResult] = useState<string | null>(null);
const [indices, setIndices] = useState<IndexOverview[]>([]);
const [strategyBoard, setStrategyBoard] = useState<StrategyBoard | null>(null);
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
const [opsRunning, setOpsRunning] = useState<string | null>(null);
const scanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadData = useCallback(async () => {
try {
const [latestResult, sectorResult, statusResult, overviewResult] = await Promise.allSettled([
fetchAPI<LatestResult>("/api/recommendations/latest"),
fetchAPI<SectorData[]>("/api/sectors/hot?limit=8"),
fetchAPI<ScanStatus>("/api/recommendations/status"),
fetchAPI<IndexOverview[]>("/api/market/overview"),
]);
const latest = latestResult.status === "fulfilled" ? latestResult.value : null;
const sectorData = sectorResult.status === "fulfilled" ? sectorResult.value : [];
const status = statusResult.status === "fulfilled" ? statusResult.value : null;
const overview = overviewResult.status === "fulfilled" ? overviewResult.value : [];
if (latest) {
setData(latest);
setMarketTemperature(latest.market_temperature ?? null);
}
setSectors(sectorData);
if (status) setScanStatus(status);
setIndices(overview);
} catch (error) {
console.error("加载数据失败:", error);
} finally {
setLoading(false);
}
}, []);
const loadSecondaryData = useCallback(async () => {
try {
const board = await fetchAPI<StrategyBoard>("/api/market/strategy-board").catch(() => null);
const ops = user?.role === "admin"
? await fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null)
: null;
setStrategyBoard(board);
setOpsStatus(ops);
} catch (error) {
console.error("加载次要数据失败:", error);
}
}, [user?.role]);
const clearScanTimeout = useCallback(() => {
if (scanTimeoutRef.current) {
clearTimeout(scanTimeoutRef.current);
scanTimeoutRef.current = null;
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
loadSecondaryData();
}, [loadSecondaryData]);
useWebSocket(
useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => {
clearScanTimeout();
if (msg.type === "scan_update") {
const modeLabel = msg.scan_mode === "realtime_today" || msg.scan_mode === "intraday" ? "今日盘中" : "收盘复盘";
setRefreshResult(`${modeLabel}更新完成,保留 ${msg.count ?? 0} 只股票`);
setRefreshing(false);
loadData();
loadSecondaryData();
setTimeout(() => setRefreshResult(null), 5000);
} else if (msg.type === "scan_error") {
setRefreshResult("更新失败,请重试");
setRefreshing(false);
setTimeout(() => setRefreshResult(null), 5000);
} else {
loadData();
loadSecondaryData();
}
}, [clearScanTimeout, loadData, loadSecondaryData]),
["scan_update", "scan_error", "llm_analysis_ready", "sector_scan_ready", "scan_complete"]
);
const handleRefresh = async () => {
setRefreshing(true);
setRefreshResult(null);
try {
const res = await postAPI<{
status: string;
message?: string;
is_trading: boolean;
}>("/api/recommendations/refresh?scan_session=manual");
if (res.status === "already_running") {
setRefreshResult(res.message || "更新正在执行中,请稍候");
} else if (res.status === "scanning") {
setRefreshResult("更新已启动,完成后自动刷新...");
}
scanTimeoutRef.current = setTimeout(() => {
setRefreshResult("更新超时,已自动刷新数据");
setRefreshing(false);
loadData();
loadSecondaryData();
setTimeout(() => setRefreshResult(null), 5000);
}, SCAN_TIMEOUT_MS);
} catch (error) {
console.error("触发更新失败:", error);
setRefreshResult("触发更新失败,请重试");
setRefreshing(false);
setTimeout(() => setRefreshResult(null), 5000);
}
};
const handleAdminAction = async (action: "update_tracking") => {
setOpsRunning(action);
setRefreshResult(null);
try {
const result = await postAPI<{ tracked?: number; win_rate?: number }>("/api/recommendations/update-tracking");
setRefreshResult(`跟踪已更新,样本 ${result.tracked ?? 0},胜率 ${clampPercent(result.win_rate ?? 0).toFixed(1)}%`);
await loadData();
await loadSecondaryData();
setTimeout(() => setRefreshResult(null), 5000);
} catch (error) {
console.error("管理员操作失败:", error);
setRefreshResult("管理员操作失败,请重试");
setTimeout(() => setRefreshResult(null), 5000);
} finally {
setOpsRunning(null);
}
};
useEffect(() => {
return () => clearScanTimeout();
}, [clearScanTimeout]);
const recommendations = data?.recommendations ?? [];
const actionable = recommendations.filter((rec) => rec.action_plan === "可操作");
const watch = recommendations.filter((rec) => rec.action_plan === "重点关注");
const observe = recommendations.filter((rec) => !["可操作", "重点关注"].includes(rec.action_plan ?? ""));
const marketSummary = useMemo(
() => buildMarketSummary(data?.strategy_profile ?? null, marketTemperature, strategyBoard, scanStatus, actionable.length, watch.length),
[actionable.length, data?.strategy_profile, marketTemperature, scanStatus, strategyBoard, watch.length]
);
const todayActions = useMemo(
() => buildActionGuides(data?.strategy_profile ?? null, strategyBoard, marketTemperature, actionable, watch, observe, sectors),
[actionable, data?.strategy_profile, marketTemperature, observe, sectors, strategyBoard, watch]
);
const focusQueue = actionable.length ? actionable : watch.length ? watch : recommendations;
if (loading) {
return (
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 space-y-4">
<div className="h-28 glass-card-static animate-shimmer" />
<div className="h-48 glass-card-static animate-shimmer" />
<div className="h-64 glass-card-static animate-shimmer" />
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
<div className="flex items-start justify-between gap-4 animate-fade-in-up">
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-bold tracking-tight"></h1>
{scanStatus?.is_trading ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] text-emerald-400">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
{user?.role === "admin" ? (
<button
onClick={handleRefresh}
disabled={refreshing}
className="rounded-xl border border-amber-500/15 bg-amber-500/10 px-4 py-2 text-xs font-medium text-amber-400 transition-colors hover:bg-amber-500/15 disabled:opacity-40"
>
{refreshing ? "更新中..." : scanStatus?.is_trading ? "盘中更新" : "立即更新"}
</button>
) : null}
</div>
</div>
{refreshResult ? (
<div className="glass-card-static animate-fade-in-up border-amber-500/15 px-4 py-2.5 text-xs text-amber-400">
{refreshResult}
</div>
) : null}
<div className="animate-fade-in-up">
<DecisionHero
board={strategyBoard}
summary={marketSummary}
actions={todayActions}
marketTemperature={marketTemperature ?? data?.market_temperature ?? null}
sectors={sectors}
focusQueue={focusQueue.slice(0, 3)}
actionableCount={actionable.length}
watchCount={watch.length}
observeCount={observe.length}
/>
</div>
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4 animate-fade-in-up">
<MarketSnapshot
marketTemperature={marketTemperature ?? data?.market_temperature ?? null}
indices={indices}
sectors={sectors}
summary={marketSummary}
/>
<AdminPanel
isAdmin={user?.role === "admin"}
opsStatus={opsStatus}
strategyProfile={data?.strategy_profile ?? null}
refreshing={refreshing}
opsRunning={opsRunning}
onRefresh={handleRefresh}
onAction={handleAdminAction}
/>
</div>
</div>
);
}
function DecisionHero({
board,
summary,
actions,
marketTemperature,
sectors,
focusQueue,
actionableCount,
watchCount,
observeCount,
}: {
board: StrategyBoard | null;
summary: ReturnType<typeof buildMarketSummary>;
actions: ReturnType<typeof buildActionGuides>;
marketTemperature: MarketTemperatureData | null;
sectors: SectorData[];
focusQueue: RecommendationData[];
actionableCount: number;
watchCount: number;
observeCount: number;
}) {
const leadingSectors = sectors.slice(0, 3);
return (
<div className="glass-card-static overflow-hidden">
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div className="p-5 md:p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400"></span>
<div className="flex flex-wrap gap-2">
<CompactBadge label="打法" value={summary.modeLabel} />
<CompactBadge label="仓位" value={summary.positionLabel} />
<CompactBadge label="风险" value={board?.risk_level ?? "等待更新"} />
</div>
</div>
<div className="mt-5 grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_160px] gap-4">
<div>
<h2 className="text-3xl md:text-4xl font-bold tracking-tight text-text-primary">{summary.headline}</h2>
<p className="mt-3 max-w-3xl text-sm leading-6 text-text-secondary">{summary.detail}</p>
</div>
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 text-center">
<div className="text-[10px] uppercase tracking-wider text-text-muted"></div>
<div className="mt-1 text-3xl font-bold font-mono tabular-nums text-amber-400">
{Math.round(marketTemperature?.temperature ?? 0)}
</div>
</div>
</div>
<div className="mt-5 grid grid-cols-1 md:grid-cols-3 gap-3">
<ActionBucket title="现在做" items={actions.priority.slice(0, 2)} tone="do" />
<ActionBucket title="等待确认" items={actions.watch.slice(0, 2)} tone="wait" />
<ActionBucket title="不要做" items={actions.avoid.slice(0, 2)} tone="avoid" />
</div>
{leadingSectors.length ? (
<div className="mt-5 flex flex-wrap gap-2">
{leadingSectors.map((sector) => (
<SectorChip key={sector.sector_code} sector={sector} />
))}
</div>
) : null}
</div>
<div className="border-t border-border-subtle bg-surface-1/50 p-5 xl:border-l xl:border-t-0">
<div className="grid grid-cols-3 gap-2">
<HeroMetric label="可操作" value={actionableCount} tone="text-red-400" />
<HeroMetric label="关注" value={watchCount} tone="text-amber-400" />
<HeroMetric label="观察" value={observeCount} tone="text-text-secondary" />
</div>
<div className="mt-5">
<div className="text-[11px] font-semibold text-text-secondary"></div>
<div className="mt-3 space-y-2.5">
{focusQueue.length ? (
focusQueue.map((rec) => <FocusStockCard key={rec.ts_code} rec={rec} />)
) : (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-6 text-center text-sm text-text-muted">
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
function MarketSnapshot({
marketTemperature,
indices,
sectors,
summary,
}: {
marketTemperature: MarketTemperatureData | null;
indices: IndexOverview[];
sectors: SectorData[];
summary: ReturnType<typeof buildMarketSummary>;
}) {
const leadingSectors = sectors.slice(0, 4);
const majorIndices = indices.slice(0, 3);
return (
<div className="glass-card-static p-4 md:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-primary"></h3>
</div>
<span className="rounded-xl bg-surface-1/70 px-3 py-1.5 text-xs font-mono tabular-nums text-text-secondary">
{Math.round(marketTemperature?.temperature ?? 0)}
</span>
</div>
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-2">
<EvidenceStat label="上涨" value={marketTemperature?.up_count ?? 0} tone="text-red-400" />
<EvidenceStat label="下跌" value={marketTemperature?.down_count ?? 0} tone="text-emerald-400" />
<EvidenceStat label="涨停" value={marketTemperature?.limit_up_count ?? 0} tone="text-amber-400" />
<EvidenceStat label="跌停" value={marketTemperature?.limit_down_count ?? 0} tone="text-cyan-400" />
</div>
{majorIndices.length ? (
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-2">
{majorIndices.map((item) => (
<div key={item.code} className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3">
<div className="text-xs font-semibold text-text-primary">{item.name}</div>
<div className="mt-1 flex items-center justify-between gap-3">
<span className="text-xs font-mono tabular-nums text-text-secondary">{item.close.toFixed(2)}</span>
<span className={`text-xs font-mono tabular-nums ${item.pct_chg >= 0 ? "text-red-400" : "text-emerald-400"}`}>
{item.pct_chg >= 0 ? "+" : ""}{item.pct_chg.toFixed(2)}%
</span>
</div>
</div>
))}
</div>
) : null}
{leadingSectors.length ? (
<div className="mt-4">
<div className="flex items-center justify-between border-b border-border-subtle pb-2">
<div className="text-[11px] font-semibold text-text-secondary"></div>
<div className="text-[10px] text-text-muted">线</div>
</div>
<div className="divide-y divide-border-subtle">
{leadingSectors.map((sector) => {
const pct = sector.realtime_pct_change ?? sector.pct_change;
return (
<div key={sector.sector_code} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-2.5 text-sm">
<div className="min-w-0 overflow-hidden">
<div className="truncate text-[13px] font-medium text-text-primary">{sector.sector_name}</div>
<div className="mt-0.5 truncate text-[11px] text-text-muted">
{sector.limit_up_count > 0 ? `${sector.limit_up_count} 涨停` : "无涨停"} · {sector.stage || "mid"}
</div>
</div>
<span className={`min-w-[4.5rem] text-right font-mono text-xs tabular-nums ${pct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
{pct >= 0 ? "+" : ""}{pct.toFixed(2)}%
</span>
</div>
);
})}
</div>
</div>
) : null}
<div className="mt-4 rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3 text-sm text-text-secondary">
{summary.headline}
</div>
</div>
);
}
function AdminPanel({
isAdmin,
opsStatus,
strategyProfile,
refreshing,
opsRunning,
onRefresh,
onAction,
}: {
isAdmin?: boolean;
opsStatus: OpsStatusResponse | null;
strategyProfile: LatestResult["strategy_profile"];
refreshing: boolean;
opsRunning: string | null;
onRefresh: () => void;
onAction: (action: "update_tracking") => void;
}) {
if (!isAdmin || !opsStatus) return null;
return (
<div className="glass-card-static p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-primary"></h3>
<p className="mt-1 text-xs text-text-muted">{opsStatus.data_freshness.message}</p>
</div>
<span className="rounded-full bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
{opsStatus.scan_running ? "扫描中" : "空闲"}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] text-text-muted">
<FreshnessPill label="市场" value={opsStatus.data_freshness.market_trade_date || "暂无"} />
<FreshnessPill label="板块" value={opsStatus.data_freshness.sector_trade_date || "暂无"} />
<FreshnessPill label="跟踪" value={opsStatus.data_freshness.tracking_trade_date || "暂无"} />
<FreshnessPill label="推荐" value={opsStatus.data_freshness.last_recommendation_created_at || "暂无"} />
</div>
{strategyProfile?.feedback_applied ? (
<div className="mt-3 rounded-2xl border border-cyan-500/15 bg-cyan-500/[0.05] p-3">
<div className="text-[10px] uppercase tracking-wider text-cyan-400 font-semibold"></div>
<div className="mt-2 space-y-1.5">
{(strategyProfile.feedback_notes?.length ? strategyProfile.feedback_notes : strategyProfile.notes || []).slice(0, 3).map((note, index) => (
<div key={`${note}-${index}`} className="text-xs leading-5 text-cyan-400/85">
{note}
</div>
))}
</div>
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
<button
onClick={onRefresh}
disabled={refreshing || !!opsRunning}
className="rounded-xl border border-amber-500/15 bg-amber-500/10 px-3 py-2 text-xs text-amber-400 disabled:opacity-40"
>
{refreshing ? "更新中..." : "立即更新"}
</button>
<button
onClick={() => onAction("update_tracking")}
disabled={refreshing || !!opsRunning}
className="rounded-xl border border-border-subtle bg-surface-1 px-3 py-2 text-xs text-text-secondary disabled:opacity-40"
>
{opsRunning === "update_tracking" ? "更新中..." : "更新跟踪"}
</button>
</div>
</div>
);
}
function HeroMetric({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3 text-center">
<div className="text-[10px] uppercase tracking-wider text-text-muted">{label}</div>
<div className={`mt-1 text-xl font-bold font-mono tabular-nums ${tone}`}>{value}</div>
</div>
);
}
function ActionBucket({ title, items, tone }: { title: string; items: string[]; tone: "do" | "wait" | "avoid" }) {
const toneClass =
tone === "do"
? "text-emerald-400 bg-emerald-500/[0.06] border-emerald-500/10"
: tone === "wait"
? "text-amber-400 bg-amber-500/[0.06] border-amber-500/10"
: "text-text-muted bg-surface-2/70 border-border-subtle";
return (
<div className={`rounded-2xl border p-3 ${toneClass}`}>
<div className="text-[11px] font-semibold">{title}</div>
<div className="mt-2 space-y-2">
{items.map((item, index) => (
<div key={`${title}-${index}`} className="text-sm leading-6 text-text-secondary">
{item}
</div>
))}
</div>
</div>
);
}
function CompactBadge({ label, value }: { label: string; value: string }) {
return (
<span className="inline-flex items-center gap-2 rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-1.5 text-xs text-text-secondary">
<span className="text-text-muted">{label}</span>
<span className="font-medium text-text-primary">{value}</span>
</span>
);
}
function SectorChip({ sector }: { sector: SectorData }) {
const pct = sector.realtime_pct_change ?? sector.pct_change;
return (
<span className="inline-flex items-center gap-2 rounded-xl border border-border-subtle bg-surface-2/70 px-3 py-1.5 text-xs">
<span className="max-w-[8rem] truncate text-text-secondary">{sector.sector_name}</span>
<span className={`font-mono tabular-nums ${pct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
{pct >= 0 ? "+" : ""}{pct.toFixed(2)}%
</span>
</span>
);
}
function FocusStockCard({ rec }: { rec: RecommendationData }) {
const stripeClass =
rec.action_plan === "可操作"
? "bg-red-400"
: rec.action_plan === "重点关注"
? "bg-amber-400"
: "bg-text-muted";
const labelClass =
rec.action_plan === "可操作"
? "text-red-400"
: rec.action_plan === "重点关注"
? "text-amber-400"
: "text-text-muted";
return (
<a
href={`/stock/${rec.ts_code}`}
className="group grid grid-cols-[3px_minmax(0,1fr)] gap-3 rounded-lg px-1 py-2 transition-colors hover:bg-surface-2/60"
>
<span className={`mt-1 h-[calc(100%-0.5rem)] rounded-full ${stripeClass}`} />
<div className="min-w-0">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex items-center gap-2">
<span className="truncate text-sm font-semibold text-text-primary">{rec.name}</span>
<span className={`shrink-0 text-[10px] font-medium ${labelClass}`}>{rec.action_plan ?? "观察"}</span>
</div>
{rec.suggested_position_pct != null ? (
<span className="shrink-0 font-mono text-[11px] tabular-nums text-text-muted">{rec.suggested_position_pct}%</span>
) : null}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-text-muted">
<span className="font-mono tabular-nums">{rec.ts_code}</span>
{rec.sector ? (
<>
<span className="text-text-muted/50">/</span>
<span className="truncate text-text-secondary">{rec.sector}</span>
</>
) : null}
</div>
<div className="mt-1.5 text-xs leading-5 text-text-secondary line-clamp-2">
{rec.decision_trace?.headline ?? rec.trigger_condition ?? rec.entry_timing ?? rec.reasons?.[0] ?? "等待新的触发条件。"}
</div>
{(rec.invalidation_condition || rec.risk_note) ? (
<div className="mt-1 text-[11px] leading-5 text-text-muted line-clamp-1">
: {rec.invalidation_condition ?? rec.risk_note}
</div>
) : null}
</div>
</a>
);
}
function EvidenceStat({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3">
<div className="text-[10px] uppercase tracking-wider text-text-muted">{label}</div>
<div className={`mt-1 text-lg font-bold font-mono tabular-nums ${tone}`}>{value}</div>
</div>
);
}
function FreshnessPill({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl bg-surface-1/70 px-2.5 py-2">
<div className="text-[10px] text-text-muted">{label}</div>
<div className="mt-1 truncate text-[11px] text-text-secondary">{value}</div>
</div>
);
}
function buildMarketSummary(
strategyProfile: LatestResult["strategy_profile"],
marketTemperature: MarketTemperatureData | null,
board: StrategyBoard | null,
scanStatus: ScanStatus | null,
actionableCount: number,
watchCount: number
) {
const temp = marketTemperature?.temperature ?? board?.metrics.temperature ?? 0;
const allowTrading = strategyProfile?.allow_trading ?? actionableCount > 0;
const headline = !allowTrading
? "防守观察"
: board?.market_regime ??
(temp >= 70 ? "主线进攻" :
temp >= 50 ? "确认机会" :
temp >= 30 ? "轻仓试错" :
"偏弱观察");
const detail =
strategyProfile?.decision_note ??
board?.summary ??
(scanStatus?.is_trading
? "当前结论:看节奏、仓位、主线强弱。"
: "最近结论:复盘主线,准备下一交易日。");
const canDo = [
!allowTrading
? "观察等待。"
: actionableCount > 0
? `只看 ${actionableCount} 只可操作标的。`
: "无可操作标的,保留观察。",
watchCount > 0 ? `关注 ${watchCount} 只确认信号。` : "盯最强板块前排。",
board?.position_suggestion ?? (strategyProfile?.max_position_pct ? `仓位上限 ${strategyProfile.max_position_pct}%。` : temp >= 50 ? "围绕主线试错。" : "控制仓位。"),
];
const cannotDo = [
board?.avoid_rules?.[0] ?? "不要追后排、跟风和没有板块支撑的个股。",
board?.avoid_rules?.[1] ?? (temp < 50 ? "不把个别异动当回暖。" : "不把盘中脉冲当主线。"),
!allowTrading || temp < 40 ? "不逆势加仓。" : "不随意切换题材。",
];
return {
headline,
detail,
canDo,
cannotDo,
modeLabel: strategyProfile?.market_stance || board?.recommended_mode || "等待更新",
positionLabel: board?.position_suggestion || (strategyProfile?.max_position_pct ? `${strategyProfile.max_position_pct}% 以内` : "等待更新"),
};
}
function clampPercent(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
function buildActionGuides(
strategyProfile: LatestResult["strategy_profile"],
board: StrategyBoard | null,
marketTemperature: MarketTemperatureData | null,
actionable: RecommendationData[],
watch: RecommendationData[],
observe: RecommendationData[],
sectors: SectorData[]
) {
const topSectors = (board?.watch_sectors?.slice(0, 2) ?? []).map((sector) => sector.sector_name);
const backupSectors = sectors.slice(0, 2).map((sector) => sector.sector_name);
const focusSectors = topSectors.length ? topSectors : backupSectors;
const temp = marketTemperature?.temperature ?? board?.metrics.temperature ?? 0;
const allowTrading = strategyProfile?.allow_trading ?? actionable.length > 0;
const priority = [
!allowTrading
? "等待清晰主线和承接。"
: actionable[0]
? `${actionable[0].name}${actionable[0].trigger_condition ? `${actionable[0].trigger_condition}` : " 的确认信号"}`
: focusSectors[0]
? `${focusSectors[0]} 前排强度。`
: "等待更清晰信号。",
focusSectors[1]
? `主线池:${focusSectors.slice(0, 2).join("、")}`
: "只围绕最强主线。",
!allowTrading ? "保守观察。" : temp >= 50 ? "做回流确认,不做无量冲高。" : "只看最强确认。",
];
const watchItems = [
watch[0]
? `${watch[0].name}:量能、回流、承接。`
: focusSectors[0]
? `${focusSectors[0]}:确认前不追。`
: "观察新主线聚焦。",
watch[1]
? `${watch[1].name}:观察队列。`
: "新热点先看板块扩散。",
observe.length > 0 ? `观察池 ${observe.length} 只。` : "暂无弱候选。",
];
const avoid = [
board?.avoid_rules?.[0] ?? "回避没有主线归属、没有触发条件的个股。",
board?.avoid_rules?.[1] ?? "回避后排补涨和尾盘情绪化拉升。",
temp < 40 ? "回避逆势加仓和高频换股。" : "回避脱离计划的追涨杀跌。",
];
return { priority, watch: watchItems, avoid };
}