1
This commit is contained in:
parent
76bad64163
commit
a0407f69a2
Binary file not shown.
@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends
|
|||||||
|
|
||||||
from app.data.tushare_client import tushare_client
|
from app.data.tushare_client import tushare_client
|
||||||
from app.data import tencent_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.data.market_breadth_client import get_market_breadth
|
||||||
from app.analysis.market_temp import build_realtime_market_temperature, calculate_market_temperature
|
from app.analysis.market_temp import build_realtime_market_temperature, calculate_market_temperature
|
||||||
from app.engine.recommender import get_latest_recommendations
|
from app.engine.recommender import get_latest_recommendations
|
||||||
@ -72,15 +73,27 @@ async def get_overview():
|
|||||||
@router.get("/strategy-board")
|
@router.get("/strategy-board")
|
||||||
async def get_strategy_board():
|
async def get_strategy_board():
|
||||||
"""获取今日市场作战面板(只读,不触发 LLM)"""
|
"""获取今日市场作战面板(只读,不触发 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
|
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")
|
@router.get("/strategy-iteration")
|
||||||
async def get_strategy_iteration(limit: int = 50):
|
async def get_strategy_iteration(limit: int = 50):
|
||||||
"""获取策略复盘迭代建议(只读,不触发 LLM)"""
|
"""获取策略复盘迭代建议(只读,不触发 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
|
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")
|
@router.get("/ops-status")
|
||||||
@ -160,14 +173,18 @@ async def get_ops_status():
|
|||||||
async def generate_strategy_board(_admin: dict = Depends(get_current_admin)):
|
async def generate_strategy_board(_admin: dict = Depends(get_current_admin)):
|
||||||
"""管理员手动生成带 LLM 说明的策略看板"""
|
"""管理员手动生成带 LLM 说明的策略看板"""
|
||||||
from app.llm.strategy_board import build_strategy_board
|
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")
|
@router.post("/generate-strategy-iteration")
|
||||||
async def generate_strategy_iteration(limit: int = 50, _admin: dict = Depends(get_current_admin)):
|
async def generate_strategy_iteration(limit: int = 50, _admin: dict = Depends(get_current_admin)):
|
||||||
"""管理员手动生成带 LLM 分析的策略复盘"""
|
"""管理员手动生成带 LLM 分析的策略复盘"""
|
||||||
from app.llm.strategy_iteration import build_strategy_iteration_report
|
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():
|
async def _overview_realtime():
|
||||||
"""盘中:腾讯实时指数行情"""
|
"""盘中:腾讯实时指数行情"""
|
||||||
|
|||||||
@ -27,6 +27,12 @@ async def build_strategy_iteration_report(limit: int = 50, include_llm: bool = F
|
|||||||
return rule_report
|
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]:
|
async def _load_recent_tracking(limit: int) -> list[dict]:
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
@ -240,6 +246,67 @@ def _build_adjustment_suggestions(
|
|||||||
return suggestions[:6]
|
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:
|
async def _generate_ai_iteration(rule_report: dict, rows: list[dict]) -> str:
|
||||||
from app.llm.client import chat_completion
|
from app.llm.client import chat_completion
|
||||||
|
|
||||||
|
|||||||
@ -121,6 +121,7 @@ async def select_strategy_profile(
|
|||||||
intraday: bool,
|
intraday: bool,
|
||||||
) -> StrategyProfile:
|
) -> StrategyProfile:
|
||||||
profile = _select_rule_profile(market_temp, hot_sectors, intraday)
|
profile = _select_rule_profile(market_temp, hot_sectors, intraday)
|
||||||
|
profile = await _apply_strategy_feedback(profile)
|
||||||
|
|
||||||
if settings.deepseek_api_key:
|
if settings.deepseek_api_key:
|
||||||
llm_profile = await _select_llm_profile(market_temp, hot_sectors, intraday, profile)
|
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")
|
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(
|
async def _select_llm_profile(
|
||||||
market_temp: MarketTemperature | None,
|
market_temp: MarketTemperature | None,
|
||||||
hot_sectors: list[SectorInfo],
|
hot_sectors: list[SectorInfo],
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"/(auth)/chat/page": "app/(auth)/chat/page.js",
|
||||||
"/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js",
|
"/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js",
|
||||||
"/(public)/page": "app/(public)/page.js",
|
"/(public)/page": "app/(public)/page.js"
|
||||||
"/(auth)/chat/page": "app/(auth)/chat/page.js"
|
|
||||||
}
|
}
|
||||||
@ -40,21 +40,17 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [latestResult, sectorResult, statusResult, overviewResult, boardResult, opsResult] = await Promise.allSettled([
|
const [latestResult, sectorResult, statusResult, overviewResult] = await Promise.allSettled([
|
||||||
fetchAPI<LatestResult>("/api/recommendations/latest"),
|
fetchAPI<LatestResult>("/api/recommendations/latest"),
|
||||||
fetchAPI<SectorData[]>("/api/sectors/hot?limit=8"),
|
fetchAPI<SectorData[]>("/api/sectors/hot?limit=8"),
|
||||||
fetchAPI<ScanStatus>("/api/recommendations/status"),
|
fetchAPI<ScanStatus>("/api/recommendations/status"),
|
||||||
fetchAPI<IndexOverview[]>("/api/market/overview"),
|
fetchAPI<IndexOverview[]>("/api/market/overview"),
|
||||||
fetchAPI<StrategyBoard>("/api/market/strategy-board"),
|
|
||||||
fetchAPI<OpsStatusResponse>("/api/market/ops-status"),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const latest = latestResult.status === "fulfilled" ? latestResult.value : null;
|
const latest = latestResult.status === "fulfilled" ? latestResult.value : null;
|
||||||
const sectorData = sectorResult.status === "fulfilled" ? sectorResult.value : [];
|
const sectorData = sectorResult.status === "fulfilled" ? sectorResult.value : [];
|
||||||
const status = statusResult.status === "fulfilled" ? statusResult.value : null;
|
const status = statusResult.status === "fulfilled" ? statusResult.value : null;
|
||||||
const overview = overviewResult.status === "fulfilled" ? overviewResult.value : [];
|
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) {
|
if (latest) {
|
||||||
setData(latest);
|
setData(latest);
|
||||||
@ -64,8 +60,6 @@ export default function DashboardPage() {
|
|||||||
setSectors(sectorData);
|
setSectors(sectorData);
|
||||||
if (status) setScanStatus(status);
|
if (status) setScanStatus(status);
|
||||||
setIndices(overview);
|
setIndices(overview);
|
||||||
setStrategyBoard(board);
|
|
||||||
setOpsStatus(ops);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("加载数据失败:", error);
|
console.error("加载数据失败:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -73,6 +67,19 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadSecondaryData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [board, ops] = await Promise.all([
|
||||||
|
fetchAPI<StrategyBoard>("/api/market/strategy-board").catch(() => null),
|
||||||
|
fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null),
|
||||||
|
]);
|
||||||
|
setStrategyBoard(board);
|
||||||
|
setOpsStatus(ops);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载次要数据失败:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clearScanTimeout = useCallback(() => {
|
const clearScanTimeout = useCallback(() => {
|
||||||
if (scanTimeoutRef.current) {
|
if (scanTimeoutRef.current) {
|
||||||
clearTimeout(scanTimeoutRef.current);
|
clearTimeout(scanTimeoutRef.current);
|
||||||
@ -84,6 +91,10 @@ export default function DashboardPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSecondaryData();
|
||||||
|
}, [loadSecondaryData]);
|
||||||
|
|
||||||
useWebSocket(
|
useWebSocket(
|
||||||
useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => {
|
useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => {
|
||||||
clearScanTimeout();
|
clearScanTimeout();
|
||||||
@ -92,6 +103,7 @@ export default function DashboardPage() {
|
|||||||
setRefreshResult(`${modeLabel}扫描完成,发现 ${msg.count ?? 0} 只股票`);
|
setRefreshResult(`${modeLabel}扫描完成,发现 ${msg.count ?? 0} 只股票`);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
loadData();
|
loadData();
|
||||||
|
loadSecondaryData();
|
||||||
setTimeout(() => setRefreshResult(null), 5000);
|
setTimeout(() => setRefreshResult(null), 5000);
|
||||||
} else if (msg.type === "scan_error") {
|
} else if (msg.type === "scan_error") {
|
||||||
setRefreshResult("扫描失败,请重试");
|
setRefreshResult("扫描失败,请重试");
|
||||||
@ -99,8 +111,9 @@ export default function DashboardPage() {
|
|||||||
setTimeout(() => setRefreshResult(null), 5000);
|
setTimeout(() => setRefreshResult(null), 5000);
|
||||||
} else {
|
} else {
|
||||||
loadData();
|
loadData();
|
||||||
|
loadSecondaryData();
|
||||||
}
|
}
|
||||||
}, [clearScanTimeout, loadData]),
|
}, [clearScanTimeout, loadData, loadSecondaryData]),
|
||||||
["scan_update", "scan_error", "llm_analysis_ready", "sector_scan_ready", "scan_complete"]
|
["scan_update", "scan_error", "llm_analysis_ready", "sector_scan_ready", "scan_complete"]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -124,6 +137,7 @@ export default function DashboardPage() {
|
|||||||
setRefreshResult("扫描超时,已自动刷新数据");
|
setRefreshResult("扫描超时,已自动刷新数据");
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
loadData();
|
loadData();
|
||||||
|
loadSecondaryData();
|
||||||
setTimeout(() => setRefreshResult(null), 5000);
|
setTimeout(() => setRefreshResult(null), 5000);
|
||||||
}, SCAN_TIMEOUT_MS);
|
}, SCAN_TIMEOUT_MS);
|
||||||
} catch (error) {
|
} 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);
|
setOpsRunning(action);
|
||||||
setRefreshResult(null);
|
setRefreshResult(null);
|
||||||
try {
|
try {
|
||||||
if (action === "update_tracking") {
|
const result = await postAPI<{ tracked?: number; win_rate?: number }>("/api/recommendations/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)}%`);
|
||||||
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("策略复盘已生成");
|
|
||||||
}
|
|
||||||
await loadData();
|
await loadData();
|
||||||
|
await loadSecondaryData();
|
||||||
setTimeout(() => setRefreshResult(null), 5000);
|
setTimeout(() => setRefreshResult(null), 5000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("管理员操作失败:", error);
|
console.error("管理员操作失败:", error);
|
||||||
@ -475,7 +482,7 @@ function AdminPanel({
|
|||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
opsRunning: string | null;
|
opsRunning: string | null;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onAction: (action: "update_tracking" | "generate_strategy_board" | "generate_strategy_iteration") => void;
|
onAction: (action: "update_tracking") => void;
|
||||||
}) {
|
}) {
|
||||||
if (!isAdmin || !opsStatus) return null;
|
if (!isAdmin || !opsStatus) return null;
|
||||||
|
|
||||||
|
|||||||
@ -6,9 +6,7 @@ import type {
|
|||||||
DayGroup,
|
DayGroup,
|
||||||
LatestResult,
|
LatestResult,
|
||||||
OpsStatusResponse,
|
OpsStatusResponse,
|
||||||
PerformanceStats,
|
|
||||||
RecommendationData,
|
RecommendationData,
|
||||||
StrategyIterationReport,
|
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import StockCard from "@/components/stock-card";
|
import StockCard from "@/components/stock-card";
|
||||||
|
|
||||||
@ -43,24 +41,18 @@ export default function RecommendationsPage() {
|
|||||||
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
|
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
|
||||||
const [historyFilter, setHistoryFilter] = useState<string>("all");
|
const [historyFilter, setHistoryFilter] = useState<string>("all");
|
||||||
const [focusTab, setFocusTab] = useState<FocusTab>("actionable");
|
const [focusTab, setFocusTab] = useState<FocusTab>("actionable");
|
||||||
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
|
||||||
const [iteration, setIteration] = useState<StrategyIterationReport | null>(null);
|
|
||||||
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
|
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [history, latestResult, perf, iterationReport, ops] = await Promise.all([
|
const [history, latestResult, ops] = await Promise.all([
|
||||||
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
||||||
fetchAPI<LatestResult>("/api/recommendations/latest").catch(() => null),
|
fetchAPI<LatestResult>("/api/recommendations/latest").catch(() => null),
|
||||||
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
|
||||||
fetchAPI<StrategyIterationReport>("/api/market/strategy-iteration?limit=50").catch(() => null),
|
|
||||||
fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null),
|
fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setDayGroups(history);
|
setDayGroups(history);
|
||||||
setLatest(latestResult);
|
setLatest(latestResult);
|
||||||
setPerformance(perf);
|
|
||||||
setIteration(iterationReport);
|
|
||||||
setOpsStatus(ops);
|
setOpsStatus(ops);
|
||||||
|
|
||||||
setExpandedDays((prev) => {
|
setExpandedDays((prev) => {
|
||||||
@ -144,8 +136,6 @@ export default function RecommendationsPage() {
|
|||||||
observe,
|
observe,
|
||||||
tracking,
|
tracking,
|
||||||
closed,
|
closed,
|
||||||
iteration,
|
|
||||||
performance,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -264,9 +254,6 @@ export default function RecommendationsPage() {
|
|||||||
只给今天真正要处理的少量标的,剩余候选不占主视图。
|
只给今天真正要处理的少量标的,剩余候选不占主视图。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/strategy" className="text-xs text-text-muted transition-colors hover:text-amber-400">
|
|
||||||
看系统校准
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{focusItems.length ? (
|
{focusItems.length ? (
|
||||||
@ -283,64 +270,32 @@ export default function RecommendationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-4 animate-fade-in-up">
|
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
||||||
<div className="glass-card-static p-4 md:p-5">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div>
|
||||||
<div>
|
<h2 className="text-sm font-semibold text-text-primary">后台观察</h2>
|
||||||
<h2 className="text-sm font-semibold text-text-primary">后台观察</h2>
|
<p className="mt-1 text-xs text-text-muted">只保留少量名字方便回看,不参与今天的主决策。</p>
|
||||||
<p className="mt-1 text-xs text-text-muted">只保留少量名字方便回看,不参与今天的主决策。</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-text-muted">{observe.length} 只</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-xs text-text-muted">{observe.length} 只</span>
|
||||||
{observe.length ? (
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{observe.slice(0, 12).map((rec) => (
|
|
||||||
<a
|
|
||||||
key={`observe-${rec.ts_code}`}
|
|
||||||
href={`/stock/${rec.ts_code}`}
|
|
||||||
className="rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-2 text-xs text-text-secondary transition-colors hover:border-amber-500/20"
|
|
||||||
>
|
|
||||||
<span className="font-semibold text-text-primary">{rec.name}</span>
|
|
||||||
<span className="mx-1 text-text-muted">·</span>
|
|
||||||
<span>{rec.sector}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-4 text-sm text-text-muted">暂无观察池标的。</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-card-static p-4 md:p-5">
|
{observe.length ? (
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold">闭环概览</div>
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
{observe.slice(0, 12).map((rec) => (
|
||||||
<SummaryMetric
|
<a
|
||||||
label="胜率"
|
key={`observe-${rec.ts_code}`}
|
||||||
value={Number((performance?.win_rate ?? 0).toFixed(1))}
|
href={`/stock/${rec.ts_code}`}
|
||||||
suffix="%"
|
className="rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-2 text-xs text-text-secondary transition-colors hover:border-amber-500/20"
|
||||||
tone={(performance?.win_rate ?? 0) >= 50 ? "text-red-400" : "text-emerald-400"}
|
>
|
||||||
/>
|
<span className="font-semibold text-text-primary">{rec.name}</span>
|
||||||
<SummaryMetric
|
<span className="mx-1 text-text-muted">·</span>
|
||||||
label="平均收益"
|
<span>{rec.sector}</span>
|
||||||
value={Number((performance?.avg_return ?? 0).toFixed(2))}
|
</a>
|
||||||
suffix="%"
|
))}
|
||||||
tone={(performance?.avg_return ?? 0) >= 0 ? "text-red-400" : "text-emerald-400"}
|
|
||||||
signed
|
|
||||||
/>
|
|
||||||
<SummaryMetric label="已跟踪" value={performance?.tracked ?? 0} tone="text-text-primary" />
|
|
||||||
<SummaryMetric label="今日筛选" value={todayCount} tone="text-text-primary" />
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{(iteration?.summary || iteration?.ai_analysis) ? (
|
<div className="mt-4 text-sm text-text-muted">暂无观察池标的。</div>
|
||||||
<div className="mt-4 rounded-2xl border border-cyan-500/10 bg-cyan-500/[0.04] p-3">
|
)}
|
||||||
<div className="text-[10px] uppercase tracking-wider text-cyan-400 font-semibold">AI 迭代提示</div>
|
|
||||||
<div className="mt-2 text-xs leading-6 text-cyan-400/85">
|
|
||||||
{iteration?.ai_analysis || iteration?.summary}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
||||||
@ -423,8 +378,6 @@ function buildFocusSummary({
|
|||||||
observe,
|
observe,
|
||||||
tracking,
|
tracking,
|
||||||
closed,
|
closed,
|
||||||
iteration,
|
|
||||||
performance,
|
|
||||||
}: {
|
}: {
|
||||||
strategyProfile: LatestResult["strategy_profile"];
|
strategyProfile: LatestResult["strategy_profile"];
|
||||||
actionable: RecommendationData[];
|
actionable: RecommendationData[];
|
||||||
@ -432,8 +385,6 @@ function buildFocusSummary({
|
|||||||
observe: RecommendationData[];
|
observe: RecommendationData[];
|
||||||
tracking: RecommendationData[];
|
tracking: RecommendationData[];
|
||||||
closed: RecommendationData[];
|
closed: RecommendationData[];
|
||||||
iteration: StrategyIterationReport | null;
|
|
||||||
performance: PerformanceStats | null;
|
|
||||||
}) {
|
}) {
|
||||||
const allowTrading = strategyProfile?.allow_trading ?? actionable.length > 0;
|
const allowTrading = strategyProfile?.allow_trading ?? actionable.length > 0;
|
||||||
const headline =
|
const headline =
|
||||||
@ -453,7 +404,7 @@ function buildFocusSummary({
|
|||||||
? "首页只保留最接近执行的标的,真正长分析进入个股详情。"
|
? "首页只保留最接近执行的标的,真正长分析进入个股详情。"
|
||||||
: watch.length > 0
|
: watch.length > 0
|
||||||
? "今天偏等待确认,不适合在大量候选里反复横跳。"
|
? "今天偏等待确认,不适合在大量候选里反复横跳。"
|
||||||
: "当前更多是维护候选池和复盘闭环,不是积极出手阶段。");
|
: "当前没有明确优势机会,先观察,不主动扩池。");
|
||||||
|
|
||||||
const now = [
|
const now = [
|
||||||
!allowTrading
|
!allowTrading
|
||||||
@ -470,15 +421,13 @@ function buildFocusSummary({
|
|||||||
: "没有确认信号前,不把观察股抬升为执行名单。",
|
: "没有确认信号前,不把观察股抬升为执行名单。",
|
||||||
tracking.length > 0
|
tracking.length > 0
|
||||||
? `${tracking.length} 只跟踪中标的继续看兑现情况,避免只看新增不看结果。`
|
? `${tracking.length} 只跟踪中标的继续看兑现情况,避免只看新增不看结果。`
|
||||||
: "如果没有跟踪样本,说明闭环还不够,要继续积累和复盘。",
|
: "没有跟踪中的标的时,就把注意力集中在今天的新结论上。",
|
||||||
];
|
];
|
||||||
|
|
||||||
const later = [
|
const later = [
|
||||||
observe.length > 0 ? `${observe.length} 只后台观察标的不应占据首页主注意力。` : "没有必要把弱标的堆在默认视图。",
|
observe.length > 0 ? `${observe.length} 只后台观察标的不应占据首页主注意力。` : "没有必要把弱标的堆在默认视图。",
|
||||||
closed.length > 0 ? `${closed.length} 只已结束样本主要用于复盘,不参与今日执行决策。` : "没有结束样本时,先积累更多闭环数据。",
|
closed.length > 0 ? `${closed.length} 只已结束样本只在需要回看时再展开,不参与今日执行决策。` : "没有结束样本时,也不需要额外制造解释性信息。",
|
||||||
iteration?.summary || performance
|
"方法说明只作为辅助,不应该压过今天的执行结论。",
|
||||||
? "策略迭代和胜率统计放在辅助区,不应该压过今天的执行结论。"
|
|
||||||
: "方法说明和统计信息只作为辅助,不抢首页决策位置。",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return { headline, detail, now, later };
|
return { headline, detail, now, later };
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { fetchAPI } from "@/lib/api";
|
import { fetchAPI } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import type {
|
import type {
|
||||||
PerformanceStats,
|
PerformanceStats,
|
||||||
StrategyAdjustment,
|
StrategyAdjustment,
|
||||||
@ -18,6 +20,8 @@ const ACTION_LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function StrategyPage() {
|
export default function StrategyPage() {
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
const [iteration, setIteration] = useState<StrategyIterationReport | null>(null);
|
const [iteration, setIteration] = useState<StrategyIterationReport | null>(null);
|
||||||
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -36,12 +40,20 @@ export default function StrategyPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!authLoading && user?.role !== "admin") {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [authLoading, router, user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authLoading || user?.role !== "admin") return;
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [authLoading, loadData, user]);
|
||||||
|
|
||||||
const diagnosis = useMemo(() => buildCalibrationDiagnosis(iteration, performance), [iteration, performance]);
|
const diagnosis = useMemo(() => buildCalibrationDiagnosis(iteration, performance), [iteration, performance]);
|
||||||
|
|
||||||
if (loading) {
|
if (authLoading || user?.role !== "admin" || loading) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 space-y-4">
|
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 space-y-4">
|
||||||
<div className="h-32 glass-card-static animate-shimmer" />
|
<div className="h-32 glass-card-static animate-shimmer" />
|
||||||
|
|||||||
@ -116,13 +116,15 @@ export function SidebarNav() {
|
|||||||
<nav className="flex-1 py-5 px-3 space-y-1">
|
<nav className="flex-1 py-5 px-3 space-y-1">
|
||||||
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" />
|
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" />
|
||||||
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" />
|
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" />
|
||||||
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="系统校准" />
|
|
||||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" />
|
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" />
|
||||||
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" />
|
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" />
|
||||||
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="个股诊断" />
|
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="个股诊断" />
|
||||||
<SideNavItem href="/chat" icon={<ChatIcon />} label="作战问答" />
|
<SideNavItem href="/chat" icon={<ChatIcon />} label="作战问答" />
|
||||||
{user?.role === "admin" && (
|
{user?.role === "admin" && (
|
||||||
<SideNavItem href="/settings" icon={<SettingsIcon />} label="系统设置" />
|
<>
|
||||||
|
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="系统校准" />
|
||||||
|
<SideNavItem href="/settings" icon={<SettingsIcon />} label="系统设置" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
@ -146,6 +148,8 @@ function MobileNavItem({ href, label, children }: { href: string; label: string;
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MobileBottomNav() {
|
export function MobileBottomNav() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed bottom-0 left-0 right-0 md:hidden z-50 bg-bg-secondary/95 backdrop-blur-xl border-t border-border-subtle">
|
<nav className="fixed bottom-0 left-0 right-0 md:hidden z-50 bg-bg-secondary/95 backdrop-blur-xl border-t border-border-subtle">
|
||||||
<div className="flex justify-around py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
|
<div className="flex justify-around py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
|
||||||
@ -155,12 +159,14 @@ export function MobileBottomNav() {
|
|||||||
<MobileNavItem href="/recommendations" label="推荐池">
|
<MobileNavItem href="/recommendations" label="推荐池">
|
||||||
<TargetIcon />
|
<TargetIcon />
|
||||||
</MobileNavItem>
|
</MobileNavItem>
|
||||||
<MobileNavItem href="/strategy" label="校准">
|
|
||||||
<StrategyIcon />
|
|
||||||
</MobileNavItem>
|
|
||||||
<MobileNavItem href="/watchlists" label="自选">
|
<MobileNavItem href="/watchlists" label="自选">
|
||||||
<WatchlistIcon />
|
<WatchlistIcon />
|
||||||
</MobileNavItem>
|
</MobileNavItem>
|
||||||
|
{user?.role === "admin" ? (
|
||||||
|
<MobileNavItem href="/strategy" label="校准">
|
||||||
|
<StrategyIcon />
|
||||||
|
</MobileNavItem>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user