From a0407f69a2bdc338eb982a9777a426348089a5f7 Mon Sep 17 00:00:00 2001
From: aaron <>
Date: Tue, 28 Apr 2026 12:46:10 +0800
Subject: [PATCH] 1
---
.../api/__pycache__/market.cpython-313.pyc | Bin 11168 -> 12035 bytes
backend/app/api/market.py | 25 ++++-
backend/app/llm/strategy_iteration.py | 67 +++++++++++
backend/app/llm/strategy_selector.py | 36 ++++++
frontend/.next/server/app-paths-manifest.json | 4 +-
frontend/src/app/(auth)/dashboard/page.tsx | 47 ++++----
.../src/app/(auth)/recommendations/page.tsx | 105 +++++-------------
frontend/src/app/(auth)/strategy/page.tsx | 16 ++-
frontend/src/components/nav.tsx | 16 ++-
9 files changed, 205 insertions(+), 111 deletions(-)
diff --git a/backend/app/api/__pycache__/market.cpython-313.pyc b/backend/app/api/__pycache__/market.cpython-313.pyc
index 0480197e9946a536d89e2dc3627866ae1d87786f..e810f9d1a953f0af66e4ac8f98cc6a4a07ea5f5b 100644
GIT binary patch
delta 2990
zcmai0YfK#16`ngY``|IVENfud<*~b9k4vj;?l3>GR>n29sF1usA_CC;^
zfy#X?!9Q$SZ4+`!(^O5=NKrziN_9meS(Tb9>Yu2;u%LoW)1PU}KU}*}V^wX>xy!D>
z67fj;-81KY=iWK@JnsJD{Izd5?kP&S0MB>(cT$nVOAZe?xzwiS{entV`m_Q#R1kJ;^yc?(+j77{O+{0+
z_K~GTbbv1>tIdx%(W?5r0zIq-0FTTBL#=aF@KvAPUi|Fmzx&&Ji~o54hM!#)C#;r4
zJaI`|y)CZGq>lZ-HprUn!EPD=GOa@h0)$+&5(ZP6(G2QDtrwvJ0TYXsv1WUqhFh67
zAjJ)XQUzyP**W`VODBl#)j!xj6p534zv7Ii0~BT@Fk`uSjn1dEZ_!>*onmIie60nf
zYw)}GAwY}Z7nUJWv+P`XARiTqr;#O5GA#;1l(Y(}q{?GrM9PYOAu2*jT2$)*85LCV
zk|YT8WLX-wR0>(?0<2efChbM(;TROYE?wYtg1Slc_>rft4wl0Qwvk!|yD)yNU%E&_
zHnv=Oy6J&06Q@@+Bdi-VZfG-CW7D}fO@`@QTGPkbMrB7nWTUt-Jpy3X#%E_c)9Fm-
zem*^klaj6(rX9dYWoLA=nujT780i?*;%Ot5(L%CWR?uG2u9{XZCb=;^3M=R`fFB;p
z&$=4Vn)NHr{4nT8k)Q%>3k$-IRd97KjDD(AzxvY6mtMQNt^^iFwrt8BTm6=^=7#cD
zdGHf=+pTBrxWk)DcwyvIx$OGr;^>XkyPnO!(4D~0hJ0$P!nN?iU5nrt{6fbBS?m~Y
zB)=xZKKWKVNZ)pM6`f=^mG;_?fl>@?RB%9`dZa926XpJVl$0VYMnxWB5h5(B79L><
zk3J|w1=XtB21JOlv@gc;V`F^iOJiIw^gWERyeGzXc3(Mt_<{G)7^e&kqMgcR!w;;*
zQ1>|6Yn}!dX&7K{%-I!3WB56o8A3RP@Fa39l=Dxc;wc19;&H@kz&Ju6**lJg{207{
z4SsqIU^jm5j+N-jv$syHRfWGakdaT^$8Xns456IdR3;x2N|o%!^7wF&{FV&+^#IH=$b&zXpsjeCNB)EcHvm9z7aV3_7!Z1k(
z^{oD>?hTOy*|hgq?+Zw9Vl^bEJAo0Oj_Vp7M=h@rEjyFSYMoR|^6tB=2Q88
z;J!!)0LSSGz>rJ_VJOtA9VFA5)Y6)vg(P~TG{F-9^O1)i)#)Pq3IrYdDyG1{tb%0-
zi}cJL_rRtyP>AgFi_fpQIydC5tqSjMd4|8xv9B#wb(7agm&_Ww3A^O)C9~`;fA<;a
zkdO6}m#eKu+T(4Z75cficDB|WWdHH|^3TFPFH-C@URQ7qKCnvP@eGnPN+A4Q+4X+}k*3xtPwyjgZ~6rLxIX
zLeu%3K+!$=CfjH`>cqUK$BXc1R&n%mM-*PiLxg@6#!wq`hR(Viyez@HPJY2qJ9|0Q
zO3GM1)RgB}KmQY8;5vAsW;yZ`x$MQ%OyLiJ1E=_Z240>@VV-K8No`iAF0`@I+#GaK
zek)Q;Y5re;;zeajsjNY<)&{<=LEWz!9Yq2JKCwnx_RnYINq80!1eZcb#1(Z1hucQU
Ttb?2V(@_Jfe8oem`FH;x173p7
delta 2174
zcmZ`)No*Ts6#oC2apJ~X>?Tg^W^wGK!PH69Ep|xSLTRhkL5a(>6hR>$!XMPA?bk_68#ddWn?f^%Csa&=8JL4{Jnew{+>bM)+
zaSwRn>tTJo25L-wRex>V3*G=5l7mdOsrJoWb@WJ3H&plR77D)hQJp*Zl16p;*^Xr+
z_{!K*WgcqvD&1X1_m|Pj*`8JQ`ZD&0N3d&F*+JDi)UaJr>-_AvGIgA<*46k3*ZNgg
zUs<9iWbZDkXIWJJE9@a9$cDTtU)#C5VJLuU8z}8+)+B?LM+9nIaaC&{3MJ7A%y5d@
zv?d&bpxW$b5K;q(t)ngBkVs3Z>5^1ZxL15s*<0bmLcuRKTZ3I*5?c_$Zm{9YkVmy5
zI7w@=XkK6)ft7%qTB?moBqfVQ06Qr|LbD#XbfD;&{?_`1#GOLe_SQ#GVc3y5tWRn%
znbA(bHdOVAUu=^$1*Nn2x7f|ZDR+%@gb8P`-rmZrY(R<1BRpIowmNo;^Nx^uF+B-s=*Ad8?JLlj1;9+|p`;HHd(nJ=cpDJr*(vr%1+(T^I%^7M@9rRX6&cLOCpfsVBK0
zAq4OoVy3#GcOQNXlC}w9rRaWs*WGn<;%lrsF=tDZl*Ftpv0S&?&jqhnE?fN5FSrM-
zyj6VS^Ye(f?dy1Ak^;FAPO_HrWrxC;Q(g2vb%_*SM2WSlN4Pz_tF$(`X3YdRz&Nf(524`)94`4s?
zroU-zlIq_>$xu>yS~D!EtX|OI0tLG$+uDg@%_nmjbq0j#fvry#=$5gF
zl`^`>li<_o)9v@Aqi@sqI|T0{;2GW|`7MHT2=n9~=7y3^o-CNlWd0v~<~iB^MhPyH
zltzH_2!=eVXZOP;G4pt6?@?0N3OW>rCzII;tzambte!IDtd@tXBTr4@iq19WN-V&kUAT;>JR|0N)}C0^R}r!#@lK$yc$e23daGBPM`M*`d`eu^|r
znY+&1dOCTTJF$wEPfE{^WJZ@Zo`X#0k~D|dv1A9WGp&smpc<`41?V{a0BuC2-Doa9
zpfzO3nS2rUk&Z^xP{#E_5$`FJDnh?GX=caG`=+XIFrbo`cAK>?r>7^f+AjDUb#$49
z1BeSU=lm}7-eZmT*~WWp>-Q`qW;?P`nfENpOln?`P+G7v$@-@Mw7+C;xx<6MqV5pC
zF8|KxO|%fv4)F!Mc(pU;m3i!XD;Z;C3zIE|rkPHEr_2-ly1Ib21pll^oP-&mgGdSf
PTU9OhEOym%k6HS^ 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.length} 只
-
-
闭环概览
-
-
= 50 ? "text-red-400" : "text-emerald-400"}
- />
- = 0 ? "text-red-400" : "text-emerald-400"}
- signed
- />
-
-
+ {observe.length ? (
+
-
- {(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 (
);