This commit is contained in:
aaron 2026-04-28 12:46:10 +08:00
parent 76bad64163
commit a0407f69a2
9 changed files with 205 additions and 111 deletions

View File

@ -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():
"""盘中:腾讯实时指数行情"""

View File

@ -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

View File

@ -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],

View File

@ -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"
}

View File

@ -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<LatestResult>("/api/recommendations/latest"),
fetchAPI<SectorData[]>("/api/sectors/hot?limit=8"),
fetchAPI<ScanStatus>("/api/recommendations/status"),
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 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<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(() => {
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;

View File

@ -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<Set<string>>(new Set());
const [historyFilter, setHistoryFilter] = useState<string>("all");
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 loadData = useCallback(async () => {
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<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),
]);
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() {
</p>
</div>
<a href="/strategy" className="text-xs text-text-muted transition-colors hover:text-amber-400">
</a>
</div>
{focusItems.length ? (
@ -283,64 +270,32 @@ export default function RecommendationsPage() {
</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">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-text-primary"></h2>
<p className="mt-1 text-xs text-text-muted">便</p>
</div>
<span className="text-xs text-text-muted">{observe.length} </span>
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-text-primary"></h2>
<p className="mt-1 text-xs text-text-muted">便</p>
</div>
{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>
)}
<span className="text-xs text-text-muted">{observe.length} </span>
</div>
<div className="glass-card-static p-4 md:p-5">
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold"></div>
<div className="mt-3 grid grid-cols-2 gap-2">
<SummaryMetric
label="胜率"
value={Number((performance?.win_rate ?? 0).toFixed(1))}
suffix="%"
tone={(performance?.win_rate ?? 0) >= 50 ? "text-red-400" : "text-emerald-400"}
/>
<SummaryMetric
label="平均收益"
value={Number((performance?.avg_return ?? 0).toFixed(2))}
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" />
{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>
{(iteration?.summary || iteration?.ai_analysis) ? (
<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 className="mt-4 text-sm text-text-muted"></div>
)}
</div>
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
@ -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 };

View File

@ -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<string, string> = {
};
export default function StrategyPage() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
const [iteration, setIteration] = useState<StrategyIterationReport | null>(null);
const [performance, setPerformance] = useState<PerformanceStats | null>(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 (
<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" />

View File

@ -116,13 +116,15 @@ export function SidebarNav() {
<nav className="flex-1 py-5 px-3 space-y-1">
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" />
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" />
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="系统校准" />
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" />
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" />
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="个股诊断" />
<SideNavItem href="/chat" icon={<ChatIcon />} label="作战问答" />
{user?.role === "admin" && (
<SideNavItem href="/settings" icon={<SettingsIcon />} label="系统设置" />
<>
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="系统校准" />
<SideNavItem href="/settings" icon={<SettingsIcon />} label="系统设置" />
</>
)}
</nav>
);
@ -146,6 +148,8 @@ function MobileNavItem({ href, label, children }: { href: string; label: string;
}
export function MobileBottomNav() {
const { user } = useAuth();
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">
<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="推荐池">
<TargetIcon />
</MobileNavItem>
<MobileNavItem href="/strategy" label="校准">
<StrategyIcon />
</MobileNavItem>
<MobileNavItem href="/watchlists" label="自选">
<WatchlistIcon />
</MobileNavItem>
{user?.role === "admin" ? (
<MobileNavItem href="/strategy" label="校准">
<StrategyIcon />
</MobileNavItem>
) : null}
</div>
</nav>
);