diff --git a/backend/app/api/__pycache__/market.cpython-313.pyc b/backend/app/api/__pycache__/market.cpython-313.pyc index 0480197e..e810f9d1 100644 Binary files a/backend/app/api/__pycache__/market.cpython-313.pyc and b/backend/app/api/__pycache__/market.cpython-313.pyc differ diff --git a/backend/app/api/market.py b/backend/app/api/market.py index c7bea56c..ed80a130 100644 --- a/backend/app/api/market.py +++ b/backend/app/api/market.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends from app.data.tushare_client import tushare_client from app.data import tencent_client +from app.data.cache import cache from app.data.market_breadth_client import get_market_breadth from app.analysis.market_temp import build_realtime_market_temperature, calculate_market_temperature from app.engine.recommender import get_latest_recommendations @@ -72,15 +73,27 @@ async def get_overview(): @router.get("/strategy-board") async def get_strategy_board(): """获取今日市场作战面板(只读,不触发 LLM)""" + cache_key = "market:strategy_board:rules" + cached = cache.get(cache_key) + if cached is not None: + return cached from app.llm.strategy_board import build_strategy_board - return await build_strategy_board(include_llm=False) + result = await build_strategy_board(include_llm=False) + cache.set(cache_key, result, settings.cache_ttl_realtime) + return result @router.get("/strategy-iteration") async def get_strategy_iteration(limit: int = 50): """获取策略复盘迭代建议(只读,不触发 LLM)""" + cache_key = f"market:strategy_iteration:{limit}:rules" + cached = cache.get(cache_key) + if cached is not None: + return cached from app.llm.strategy_iteration import build_strategy_iteration_report - return await build_strategy_iteration_report(limit=limit, include_llm=False) + result = await build_strategy_iteration_report(limit=limit, include_llm=False) + cache.set(cache_key, result, settings.cache_ttl_realtime) + return result @router.get("/ops-status") @@ -160,14 +173,18 @@ async def get_ops_status(): async def generate_strategy_board(_admin: dict = Depends(get_current_admin)): """管理员手动生成带 LLM 说明的策略看板""" from app.llm.strategy_board import build_strategy_board - return await build_strategy_board(include_llm=True) + result = await build_strategy_board(include_llm=True) + cache.delete("market:strategy_board:rules") + return result @router.post("/generate-strategy-iteration") async def generate_strategy_iteration(limit: int = 50, _admin: dict = Depends(get_current_admin)): """管理员手动生成带 LLM 分析的策略复盘""" from app.llm.strategy_iteration import build_strategy_iteration_report - return await build_strategy_iteration_report(limit=limit, include_llm=True) + result = await build_strategy_iteration_report(limit=limit, include_llm=True) + cache.delete(f"market:strategy_iteration:{limit}:rules") + return result async def _overview_realtime(): """盘中:腾讯实时指数行情""" diff --git a/backend/app/llm/strategy_iteration.py b/backend/app/llm/strategy_iteration.py index 49264eb5..8b4770b1 100644 --- a/backend/app/llm/strategy_iteration.py +++ b/backend/app/llm/strategy_iteration.py @@ -27,6 +27,12 @@ async def build_strategy_iteration_report(limit: int = 50, include_llm: bool = F return rule_report +async def build_strategy_feedback_controls(limit: int = 50) -> dict: + rows = await _load_recent_tracking(limit) + report = _build_rule_report(rows) + return _derive_feedback_controls(report) + + async def _load_recent_tracking(limit: int) -> list[dict]: from sqlalchemy import text from app.db.database import get_db @@ -240,6 +246,67 @@ def _build_adjustment_suggestions( return suggestions[:6] +def _derive_feedback_controls(report: dict) -> dict: + suggestions = report.get("adjustment_suggestions", []) or [] + sample_size = int(report.get("sample_size") or 0) + + controls = { + "sample_size": sample_size, + "enabled": sample_size >= 10, + "buy_threshold_delta": 0, + "max_position_pct_delta": 0, + "actionable_limit_delta": 0, + "watch_limit_delta": 0, + "force_defensive": False, + "notes": [], + } + + if sample_size < 10: + controls["notes"].append("样本不足,暂不启用自动回写。") + return controls + + promote_count = 0 + tighten_count = 0 + reduce_count = 0 + + for item in suggestions[:6]: + action = item.get("action") + reason = item.get("reason", "") + + if action == "promote": + promote_count += 1 + controls["buy_threshold_delta"] -= 1 + controls["watch_limit_delta"] += 1 + elif action == "tighten": + tighten_count += 1 + controls["buy_threshold_delta"] += 1 + controls["actionable_limit_delta"] -= 1 + controls["max_position_pct_delta"] -= 5 + elif action == "reduce": + reduce_count += 1 + controls["buy_threshold_delta"] += 1 + controls["watch_limit_delta"] -= 1 + + if "弱势市场" in reason or item.get("target") == "defensive_watch": + controls["force_defensive"] = True + + controls["buy_threshold_delta"] = max(-2, min(3, controls["buy_threshold_delta"])) + controls["max_position_pct_delta"] = max(-10, min(5, controls["max_position_pct_delta"])) + controls["actionable_limit_delta"] = max(-2, min(1, controls["actionable_limit_delta"])) + controls["watch_limit_delta"] = max(-2, min(2, controls["watch_limit_delta"])) + + if controls["force_defensive"]: + controls["notes"].append("最近弱市亏损样本偏多,优先启用防守约束。") + elif tighten_count > promote_count: + controls["notes"].append("最近失效样本偏多,整体建议略收紧。") + elif promote_count > 0 and reduce_count == 0: + controls["notes"].append("最近有效样本改善,可适度放宽观察与出手空间。") + else: + controls["notes"].append("最近样本无明显单边倾向,仅做轻微校正。") + + return controls + + async def _generate_ai_iteration(rule_report: dict, rows: list[dict]) -> str: from app.llm.client import chat_completion diff --git a/backend/app/llm/strategy_selector.py b/backend/app/llm/strategy_selector.py index 721fd01c..c91ff944 100644 --- a/backend/app/llm/strategy_selector.py +++ b/backend/app/llm/strategy_selector.py @@ -121,6 +121,7 @@ async def select_strategy_profile( intraday: bool, ) -> StrategyProfile: profile = _select_rule_profile(market_temp, hot_sectors, intraday) + profile = await _apply_strategy_feedback(profile) if settings.deepseek_api_key: llm_profile = await _select_llm_profile(market_temp, hot_sectors, intraday, profile) @@ -151,6 +152,41 @@ def _select_rule_profile( return get_strategy_profile_by_id("defensive_watch") +async def _apply_strategy_feedback(profile: StrategyProfile) -> StrategyProfile: + from app.llm.strategy_iteration import build_strategy_feedback_controls + + try: + controls = await build_strategy_feedback_controls(limit=50) + except Exception as e: + logger.debug(f"策略反馈控制生成失败: {e}") + return profile + + if not controls.get("enabled"): + return profile + + updated = profile.model_copy(deep=True) + + if controls.get("force_defensive"): + updated.allow_trading = False + updated.actionable_limit = 0 + updated.watch_limit = min(updated.watch_limit, 3) + updated.max_position_pct = min(updated.max_position_pct, 10) + updated.market_stance = "防守观察" + + updated.buy_threshold = max(updated.min_score, min(updated.buy_threshold + int(controls.get("buy_threshold_delta") or 0), 80)) + updated.max_position_pct = max(0, min(updated.max_position_pct + int(controls.get("max_position_pct_delta") or 0), 40)) + updated.actionable_limit = max(0, min(updated.actionable_limit + int(controls.get("actionable_limit_delta") or 0), settings.actionable_limit)) + updated.watch_limit = max(1, min(updated.watch_limit + int(controls.get("watch_limit_delta") or 0), settings.watch_limit)) + + notes = controls.get("notes") or [] + if notes: + updated.notes.extend(notes[:2]) + updated.decision_note = notes[0] + + updated.generated_by = f"{updated.generated_by}+feedback" + return updated + + async def _select_llm_profile( market_temp: MarketTemperature | None, hot_sectors: list[SectorInfo], diff --git a/frontend/.next/server/app-paths-manifest.json b/frontend/.next/server/app-paths-manifest.json index 55fc5abc..1f6f5ce8 100644 --- a/frontend/.next/server/app-paths-manifest.json +++ b/frontend/.next/server/app-paths-manifest.json @@ -1,5 +1,5 @@ { + "/(auth)/chat/page": "app/(auth)/chat/page.js", "/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js", - "/(public)/page": "app/(public)/page.js", - "/(auth)/chat/page": "app/(auth)/chat/page.js" + "/(public)/page": "app/(public)/page.js" } \ No newline at end of file diff --git a/frontend/src/app/(auth)/dashboard/page.tsx b/frontend/src/app/(auth)/dashboard/page.tsx index 4b39885b..e70ce218 100644 --- a/frontend/src/app/(auth)/dashboard/page.tsx +++ b/frontend/src/app/(auth)/dashboard/page.tsx @@ -40,21 +40,17 @@ export default function DashboardPage() { const loadData = useCallback(async () => { try { - const [latestResult, sectorResult, statusResult, overviewResult, boardResult, opsResult] = await Promise.allSettled([ + const [latestResult, sectorResult, statusResult, overviewResult] = await Promise.allSettled([ fetchAPI("/api/recommendations/latest"), fetchAPI("/api/sectors/hot?limit=8"), fetchAPI("/api/recommendations/status"), fetchAPI("/api/market/overview"), - fetchAPI("/api/market/strategy-board"), - fetchAPI("/api/market/ops-status"), ]); 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 : []; - const board = boardResult.status === "fulfilled" ? boardResult.value : null; - const ops = opsResult.status === "fulfilled" ? opsResult.value : null; if (latest) { setData(latest); @@ -64,8 +60,6 @@ export default function DashboardPage() { setSectors(sectorData); if (status) setScanStatus(status); setIndices(overview); - setStrategyBoard(board); - setOpsStatus(ops); } catch (error) { console.error("加载数据失败:", error); } finally { @@ -73,6 +67,19 @@ export default function DashboardPage() { } }, []); + const loadSecondaryData = useCallback(async () => { + try { + const [board, ops] = await Promise.all([ + fetchAPI("/api/market/strategy-board").catch(() => null), + fetchAPI("/api/market/ops-status").catch(() => null), + ]); + setStrategyBoard(board); + setOpsStatus(ops); + } catch (error) { + console.error("加载次要数据失败:", error); + } + }, []); + const clearScanTimeout = useCallback(() => { if (scanTimeoutRef.current) { clearTimeout(scanTimeoutRef.current); @@ -84,6 +91,10 @@ export default function DashboardPage() { loadData(); }, [loadData]); + useEffect(() => { + loadSecondaryData(); + }, [loadSecondaryData]); + useWebSocket( useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => { clearScanTimeout(); @@ -92,6 +103,7 @@ export default function DashboardPage() { setRefreshResult(`${modeLabel}扫描完成,发现 ${msg.count ?? 0} 只股票`); setRefreshing(false); loadData(); + loadSecondaryData(); setTimeout(() => setRefreshResult(null), 5000); } else if (msg.type === "scan_error") { setRefreshResult("扫描失败,请重试"); @@ -99,8 +111,9 @@ export default function DashboardPage() { setTimeout(() => setRefreshResult(null), 5000); } else { loadData(); + loadSecondaryData(); } - }, [clearScanTimeout, loadData]), + }, [clearScanTimeout, loadData, loadSecondaryData]), ["scan_update", "scan_error", "llm_analysis_ready", "sector_scan_ready", "scan_complete"] ); @@ -124,6 +137,7 @@ export default function DashboardPage() { setRefreshResult("扫描超时,已自动刷新数据"); setRefreshing(false); loadData(); + loadSecondaryData(); setTimeout(() => setRefreshResult(null), 5000); }, SCAN_TIMEOUT_MS); } catch (error) { @@ -134,21 +148,14 @@ export default function DashboardPage() { } }; - const handleAdminAction = async (action: "update_tracking" | "generate_strategy_board" | "generate_strategy_iteration") => { + const handleAdminAction = async (action: "update_tracking") => { setOpsRunning(action); setRefreshResult(null); try { - if (action === "update_tracking") { - const result = await postAPI<{ tracked?: number; win_rate?: number }>("/api/recommendations/update-tracking"); - setRefreshResult(`跟踪已更新,样本 ${result.tracked ?? 0},胜率 ${(result.win_rate ?? 0).toFixed(1)}%`); - } else if (action === "generate_strategy_board") { - await postAPI("/api/market/generate-strategy-board"); - setRefreshResult("策略板已生成"); - } else { - await postAPI("/api/market/generate-strategy-iteration"); - setRefreshResult("策略复盘已生成"); - } + const result = await postAPI<{ tracked?: number; win_rate?: number }>("/api/recommendations/update-tracking"); + setRefreshResult(`跟踪已更新,样本 ${result.tracked ?? 0},胜率 ${(result.win_rate ?? 0).toFixed(1)}%`); await loadData(); + await loadSecondaryData(); setTimeout(() => setRefreshResult(null), 5000); } catch (error) { console.error("管理员操作失败:", error); @@ -475,7 +482,7 @@ function AdminPanel({ refreshing: boolean; opsRunning: string | null; onRefresh: () => void; - onAction: (action: "update_tracking" | "generate_strategy_board" | "generate_strategy_iteration") => void; + onAction: (action: "update_tracking") => void; }) { if (!isAdmin || !opsStatus) return null; diff --git a/frontend/src/app/(auth)/recommendations/page.tsx b/frontend/src/app/(auth)/recommendations/page.tsx index 377f3a81..213b3665 100644 --- a/frontend/src/app/(auth)/recommendations/page.tsx +++ b/frontend/src/app/(auth)/recommendations/page.tsx @@ -6,9 +6,7 @@ import type { DayGroup, LatestResult, OpsStatusResponse, - PerformanceStats, RecommendationData, - StrategyIterationReport, } from "@/lib/api"; import StockCard from "@/components/stock-card"; @@ -43,24 +41,18 @@ export default function RecommendationsPage() { const [expandedDays, setExpandedDays] = useState>(new Set()); const [historyFilter, setHistoryFilter] = useState("all"); const [focusTab, setFocusTab] = useState("actionable"); - const [performance, setPerformance] = useState(null); - const [iteration, setIteration] = useState(null); const [opsStatus, setOpsStatus] = useState(null); const loadData = useCallback(async () => { try { - const [history, latestResult, perf, iterationReport, ops] = await Promise.all([ + const [history, latestResult, ops] = await Promise.all([ fetchAPI("/api/recommendations/history?days=14"), fetchAPI("/api/recommendations/latest").catch(() => null), - fetchAPI("/api/recommendations/performance").catch(() => null), - fetchAPI("/api/market/strategy-iteration?limit=50").catch(() => null), fetchAPI("/api/market/ops-status").catch(() => null), ]); setDayGroups(history); setLatest(latestResult); - setPerformance(perf); - setIteration(iterationReport); setOpsStatus(ops); setExpandedDays((prev) => { @@ -144,8 +136,6 @@ export default function RecommendationsPage() { observe, tracking, closed, - iteration, - performance, }); return ( @@ -264,9 +254,6 @@ export default function RecommendationsPage() { 只给今天真正要处理的少量标的,剩余候选不占主视图。

- - 看系统校准 - {focusItems.length ? ( @@ -283,64 +270,32 @@ export default function RecommendationsPage() { -
-
-
-
-

后台观察

-

只保留少量名字方便回看,不参与今天的主决策。

-
- {observe.length} 只 +
+
+
+

后台观察

+

只保留少量名字方便回看,不参与今天的主决策。

- - {observe.length ? ( -
- {observe.slice(0, 12).map((rec) => ( - - {rec.name} - · - {rec.sector} - - ))} -
- ) : ( -
暂无观察池标的。
- )} + {observe.length} 只
-
-
闭环概览
-
- = 50 ? "text-red-400" : "text-emerald-400"} - /> - = 0 ? "text-red-400" : "text-emerald-400"} - signed - /> - - + {observe.length ? ( +
+ {observe.slice(0, 12).map((rec) => ( + + {rec.name} + · + {rec.sector} + + ))}
- - {(iteration?.summary || iteration?.ai_analysis) ? ( -
-
AI 迭代提示
-
- {iteration?.ai_analysis || iteration?.summary} -
-
- ) : null} -
+ ) : ( +
暂无观察池标的。
+ )}
@@ -423,8 +378,6 @@ function buildFocusSummary({ observe, tracking, closed, - iteration, - performance, }: { strategyProfile: LatestResult["strategy_profile"]; actionable: RecommendationData[]; @@ -432,8 +385,6 @@ function buildFocusSummary({ observe: RecommendationData[]; tracking: RecommendationData[]; closed: RecommendationData[]; - iteration: StrategyIterationReport | null; - performance: PerformanceStats | null; }) { const allowTrading = strategyProfile?.allow_trading ?? actionable.length > 0; const headline = @@ -453,7 +404,7 @@ function buildFocusSummary({ ? "首页只保留最接近执行的标的,真正长分析进入个股详情。" : watch.length > 0 ? "今天偏等待确认,不适合在大量候选里反复横跳。" - : "当前更多是维护候选池和复盘闭环,不是积极出手阶段。"); + : "当前没有明确优势机会,先观察,不主动扩池。"); const now = [ !allowTrading @@ -470,15 +421,13 @@ function buildFocusSummary({ : "没有确认信号前,不把观察股抬升为执行名单。", tracking.length > 0 ? `${tracking.length} 只跟踪中标的继续看兑现情况,避免只看新增不看结果。` - : "如果没有跟踪样本,说明闭环还不够,要继续积累和复盘。", + : "没有跟踪中的标的时,就把注意力集中在今天的新结论上。", ]; const later = [ observe.length > 0 ? `${observe.length} 只后台观察标的不应占据首页主注意力。` : "没有必要把弱标的堆在默认视图。", - closed.length > 0 ? `${closed.length} 只已结束样本主要用于复盘,不参与今日执行决策。` : "没有结束样本时,先积累更多闭环数据。", - iteration?.summary || performance - ? "策略迭代和胜率统计放在辅助区,不应该压过今天的执行结论。" - : "方法说明和统计信息只作为辅助,不抢首页决策位置。", + closed.length > 0 ? `${closed.length} 只已结束样本只在需要回看时再展开,不参与今日执行决策。` : "没有结束样本时,也不需要额外制造解释性信息。", + "方法说明只作为辅助,不应该压过今天的执行结论。", ]; return { headline, detail, now, later }; diff --git a/frontend/src/app/(auth)/strategy/page.tsx b/frontend/src/app/(auth)/strategy/page.tsx index 2456a6e5..4b28905a 100644 --- a/frontend/src/app/(auth)/strategy/page.tsx +++ b/frontend/src/app/(auth)/strategy/page.tsx @@ -1,7 +1,9 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; import { fetchAPI } from "@/lib/api"; +import { useAuth } from "@/hooks/use-auth"; import type { PerformanceStats, StrategyAdjustment, @@ -18,6 +20,8 @@ const ACTION_LABELS: Record = { }; export default function StrategyPage() { + const { user, loading: authLoading } = useAuth(); + const router = useRouter(); const [iteration, setIteration] = useState(null); const [performance, setPerformance] = useState(null); const [loading, setLoading] = useState(true); @@ -36,12 +40,20 @@ export default function StrategyPage() { }, []); useEffect(() => { + if (!authLoading && user?.role !== "admin") { + router.replace("/dashboard"); + return; + } + }, [authLoading, router, user]); + + useEffect(() => { + if (authLoading || user?.role !== "admin") return; loadData(); - }, [loadData]); + }, [authLoading, loadData, user]); const diagnosis = useMemo(() => buildCalibrationDiagnosis(iteration, performance), [iteration, performance]); - if (loading) { + if (authLoading || user?.role !== "admin" || loading) { return (
diff --git a/frontend/src/components/nav.tsx b/frontend/src/components/nav.tsx index 9d1c2b8d..9f1e6866 100644 --- a/frontend/src/components/nav.tsx +++ b/frontend/src/components/nav.tsx @@ -116,13 +116,15 @@ export function SidebarNav() { ); @@ -146,6 +148,8 @@ function MobileNavItem({ href, label, children }: { href: string; label: string; } export function MobileBottomNav() { + const { user } = useAuth(); + return ( );