This commit is contained in:
aaron 2026-04-30 23:29:52 +08:00
parent 41d19b5a36
commit fa02e931de
9 changed files with 207 additions and 52 deletions

View File

@ -284,6 +284,18 @@ async def get_performance_stats() -> dict:
"""获取推荐胜率统计"""
try:
from sqlalchemy import text
latest_tracked_sql = (
"WITH latest_tracked AS ("
" SELECT r.id AS recommendation_id, t.pct_from_entry, t.max_return_pct, "
" t.max_drawdown_pct, t.hit_target, t.hit_stop_loss "
" FROM recommendations r "
" INNER JOIN recommendation_tracking t ON t.recommendation_id = r.id "
" INNER JOIN ("
" SELECT recommendation_id, MAX(id) as max_id "
" FROM recommendation_tracking GROUP BY recommendation_id"
" ) latest ON t.id = latest.max_id"
") "
)
async with get_db() as db:
# 总推荐数
result = await db.execute(
@ -294,8 +306,8 @@ async def get_performance_stats() -> dict:
# 有跟踪记录的推荐
result = await db.execute(
text(
"SELECT COUNT(DISTINCT r.id) FROM recommendations r "
"INNER JOIN recommendation_tracking t ON t.recommendation_id = r.id"
latest_tracked_sql +
"SELECT COUNT(*) FROM latest_tracked"
)
)
tracked = result.scalar() or 0
@ -303,14 +315,8 @@ async def get_performance_stats() -> dict:
# 胜率基于最新跟踪日的最终 pct正值=盈利,负值=亏损)
result = await db.execute(
text(
"SELECT COUNT(*) FROM ("
" SELECT t.recommendation_id, t.pct_from_entry as latest_pct "
" FROM recommendation_tracking t "
" INNER JOIN ("
" SELECT recommendation_id, MAX(id) as max_id "
" FROM recommendation_tracking GROUP BY recommendation_id"
" ) latest ON t.id = latest.max_id"
") WHERE latest_pct > 0"
latest_tracked_sql +
"SELECT COUNT(*) FROM latest_tracked WHERE pct_from_entry > 0"
)
)
winning = result.scalar() or 0
@ -318,14 +324,8 @@ async def get_performance_stats() -> dict:
# 平均收益(基于最新跟踪日的 pct
result = await db.execute(
text(
"SELECT AVG(latest_pct) FROM ("
" SELECT t.pct_from_entry as latest_pct "
" FROM recommendation_tracking t "
" INNER JOIN ("
" SELECT recommendation_id, MAX(id) as max_id "
" FROM recommendation_tracking GROUP BY recommendation_id"
" ) latest ON t.id = latest.max_id"
")"
latest_tracked_sql +
"SELECT AVG(pct_from_entry) FROM latest_tracked"
)
)
avg_return = result.scalar()
@ -334,8 +334,8 @@ async def get_performance_stats() -> dict:
# 达到目标价的推荐
result = await db.execute(
text(
"SELECT COUNT(DISTINCT recommendation_id) FROM recommendation_tracking "
"WHERE hit_target = 1"
latest_tracked_sql +
"SELECT COUNT(*) FROM latest_tracked WHERE hit_target = 1"
)
)
hit_target_count = result.scalar() or 0
@ -343,8 +343,8 @@ async def get_performance_stats() -> dict:
# 触发止损的推荐
result = await db.execute(
text(
"SELECT COUNT(DISTINCT recommendation_id) FROM recommendation_tracking "
"WHERE hit_stop_loss = 1"
latest_tracked_sql +
"SELECT COUNT(*) FROM latest_tracked WHERE hit_stop_loss = 1"
)
)
hit_stop_count = result.scalar() or 0
@ -364,14 +364,8 @@ async def get_performance_stats() -> dict:
# 最大浮盈/最大回撤统计
result = await db.execute(
text(
"SELECT AVG(max_return_pct), AVG(max_drawdown_pct) FROM ("
" SELECT t.recommendation_id, t.max_return_pct, t.max_drawdown_pct "
" FROM recommendation_tracking t "
" INNER JOIN ("
" SELECT recommendation_id, MAX(id) as max_id "
" FROM recommendation_tracking GROUP BY recommendation_id"
" ) latest ON t.id = latest.max_id"
")"
latest_tracked_sql +
"SELECT AVG(max_return_pct), AVG(max_drawdown_pct) FROM latest_tracked"
)
)
avg_extremes = result.fetchone()
@ -425,6 +419,7 @@ async def get_performance_stats() -> dict:
"created_at": str(r["created_at"])[:10] if r["created_at"] else "",
})
winning = min(winning, tracked)
win_rate = round(winning / tracked * 100, 1) if tracked > 0 else 0
return {

View File

@ -27,6 +27,7 @@ TOOL_LABELS = {
"get_user_watchlist_snapshot": "读取自选股作战池",
"get_stock_kline": "查询K线数据",
"get_stock_capital_flow": "查询资金流向",
"diagnose_stock": "生成个股会诊",
"search_stock": "搜索股票",
}

View File

@ -34,7 +34,7 @@ ENHANCE_USER_TEMPLATE = """\
请对该股票进行 2-3 句话的深度分析"""
CHAT_SYSTEM_PROMPT = """\
你是 A 股投研作战台里的 AI 作战助理不是泛化闲聊机器人你的核心任务是解释系统已经生成的结果并帮助用户把市场板块推荐和自选股串成可执行判断
你是 A 股投研作战台里的系统智能体不是泛化闲聊机器人你的核心任务是回答所有与本系统有关的问题并把市场板块推荐自选股个股诊断和系统校准串成可执行判断
你的能力
1. 查询今日作战结论包括市场状态今日打法建议仓位重点板块和规避规则
@ -42,12 +42,15 @@ CHAT_SYSTEM_PROMPT = """\
3. 查询当前用户的自选股池与最新建议
4. 查询个股K线技术面资金流向数据
5. 搜索股票代码并把结果放回当前交易语境中分析
6. 对单只股票生成系统化会诊输出结论触发条件失效条件仓位边界和风险清单
重要提醒
- 回答用户关于"今天市场怎么样"之类的问题时必须调用 get_realtime_indices 获取实时指数数据
- 回答用户关于"今天该怎么做""当前推荐怎么看""自选股该怎么处理"这类问题时优先调用 get_strategy_boardget_latest_recommendationsget_user_watchlist_snapshot
- 回答用户关于某只股票能不能看是否该买持仓怎么处理为什么涨跌是否要复盘时必须先 search_stock如果用户没给标准 ts_code再调用 diagnose_stock必要时补充 get_stock_capital_flowget_stock_technical_signal
- 盘中时段9:30-15:00必须使用实时数据盘后时段使用当日收盘或最近一次系统生成的数据
- 不要脱离系统上下文泛泛而谈必须先调用工具获取最新结果再回答
- A 股优先看资金顺势主线板块量价承接和位置RSI/MACD/KDJ 只做节奏与风控确认不能因超买超卖本身直接否决或买入
回答要求
1. 使用工具获取最新数据后再回答不要凭空编造数据

View File

@ -44,6 +44,8 @@ async def execute_tool(name: str, arguments: dict) -> str:
return await _search_stock(arguments["keyword"])
elif name == "get_stock_technical_signal":
return await _get_stock_technical_signal(arguments["ts_code"])
elif name == "diagnose_stock":
return await _diagnose_stock(arguments["ts_code"], arguments.get("mode", "entry"))
elif name == "get_sector_performance":
return await _get_sector_performance(arguments["sector_name"])
elif name == "get_realtime_indices":
@ -227,6 +229,126 @@ async def _get_stock_technical_signal(ts_code: str) -> str:
return json.dumps(data, ensure_ascii=False, default=str)
async def _diagnose_stock(ts_code: str, mode: str = "entry") -> str:
"""生成系统化个股会诊,供作战问答智能体调用。"""
from sqlalchemy import text
from app.db.database import get_db
from app.db import tables
from app.llm.client import chat_completion
mode_map = {
"entry": "建仓前诊断",
"holding": "持仓复核",
"review": "回撤复盘",
"tracking": "继续跟踪",
}
mode = mode if mode in mode_map else "entry"
strategy_board = await _get_strategy_board()
latest_recommendations = await _get_latest_recommendations()
hot_sectors = await _get_hot_sectors(8)
kline = await _get_stock_kline(ts_code, 80)
capital_flow = await _get_stock_capital_flow(ts_code, 15)
technical_signal = await _get_stock_technical_signal(ts_code)
latest_rec = None
stock_name = ts_code
try:
recs = json.loads(latest_recommendations)
latest_rec = next((item for item in recs if item.get("ts_code") == ts_code), None)
except Exception:
latest_rec = None
if latest_rec and latest_rec.get("name"):
stock_name = latest_rec["name"]
recent_diagnoses = []
try:
async with get_db() as db:
rows = (await db.execute(
text(
"SELECT name, diagnosis_mode, diagnosis, created_at "
"FROM stock_diagnoses WHERE ts_code = :ts_code "
"ORDER BY created_at DESC, id DESC LIMIT 3"
),
{"ts_code": ts_code},
)).fetchall()
recent_diagnoses = [dict(row._mapping) for row in rows]
if recent_diagnoses and recent_diagnoses[0].get("name"):
stock_name = recent_diagnoses[0]["name"]
except Exception:
recent_diagnoses = []
prompt = f"""请在 A 股作战台语境下,对 {ts_code} 做一次系统化个股会诊。
诊断模式: {mode_map[mode]}
今日作战结论:
{strategy_board}
最新推荐池中该股记录:
{json.dumps(latest_rec, ensure_ascii=False, default=str) if latest_rec else "不在最新推荐池"}
热门板块:
{hot_sectors}
K线与技术指标:
{kline}
资金流:
{capital_flow}
技术信号:
{technical_signal}
最近诊断:
{json.dumps(recent_diagnoses, ensure_ascii=False, default=str)}
输出要求:
- 先给明确结论只能是可操作 / 重点关注 / 观察 / 回避
- 明确当前动作触发条件失效条件仓位边界下一步观察点
- A 股优先看资金顺势主线板块量价承接和位置技术指标只做节奏与风控确认
- RSIMACDKDJ 的超买超卖不能单独决定买卖
- 不写传统研报不堆原始数据不承诺收益
- Markdown 输出保持简洁"""
resp = await chat_completion([
{
"role": "system",
"content": (
"你是 A 股投研作战台的个股会诊智能体。"
"你必须融合系统作战结论、板块、推荐池、资金流、K线和技术信号"
"输出可执行但带风险边界的会诊结论。"
),
},
{"role": "user", "content": prompt},
])
if not resp or not resp.content:
return json.dumps({"error": "个股会诊生成失败LLM 未返回内容"}, ensure_ascii=False)
diagnosis = resp.content.strip()
try:
async with get_db() as db:
await db.execute(
tables.stock_diagnoses_table.insert().values(
ts_code=ts_code,
name=stock_name,
diagnosis_mode=mode,
diagnosis=diagnosis,
)
)
await db.commit()
except Exception as e:
logger.warning(f"保存聊天会诊结果失败 {ts_code}: {e}")
return json.dumps({
"ts_code": ts_code,
"name": stock_name,
"mode": mode,
"diagnosis": diagnosis,
"saved": True,
}, ensure_ascii=False, default=str)
async def _get_sector_performance(sector_name: str) -> str:
"""获取板块表现数据"""
from app.engine.recommender import get_latest_sectors

View File

@ -145,6 +145,28 @@ CHAT_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "diagnose_stock",
"description": "对单只 A 股做系统化个股会诊融合今日作战结论、推荐池、板块、K线、资金流和技术信号输出结论、触发条件、失效条件和风险边界",
"parameters": {
"type": "object",
"properties": {
"ts_code": {
"type": "string",
"description": "股票代码,如 '000001.SZ'。如果用户只给名称,应先调用 search_stock 找到 ts_code",
},
"mode": {
"type": "string",
"enum": ["entry", "holding", "review", "tracking"],
"description": "诊断模式entry 建仓前holding 持仓复核review 回撤复盘tracking 继续跟踪。默认 entry",
},
},
"required": ["ts_code"],
},
},
},
{
"type": "function",
"function": {

View File

@ -12,23 +12,23 @@ interface DisplayMessage {
const QUICK_QUESTIONS = [
"结合今日作战结论,告诉我今天应该重点看什么。",
"把当前推荐池分成可操作、重点关注和仅观察三层讲给我。",
"诊断一下 300750.SZ给出触发条件和失效条件。",
"看看我的自选股里哪些需要明天优先盯盘。",
"如果今天只允许做一个方向,你建议我盯哪个主线,为什么",
"复盘当前推荐池,哪些信号最近更有效",
];
const CHAT_SCENES = [
{
title: "问今日打法",
description: "进攻 / 试错 / 防守",
title: "市场",
description: "打法 / 仓位 / 风险",
},
{
title: "问推荐池",
description: "进池原因 / 触发 / 放弃",
title: "个股",
description: "诊断 / 触发 / 失效",
},
{
title: "问自选股",
description: "观察池 / 候选池 / 持仓池",
title: "系统",
description: "推荐池 / 自选股 / 校准",
},
];
@ -108,15 +108,13 @@ export default function ChatPage() {
<aside className="hidden xl:flex xl:flex-col xl:gap-4">
<div className="glass-card-static p-5 animate-fade-in-up">
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-cyan-400">
Combat Chat
System Agent
</div>
<h1 className="mt-2 text-xl font-bold tracking-tight">AI </h1>
<h1 className="mt-2 text-xl font-bold tracking-tight"></h1>
</div>
<div className="glass-card-static p-5 animate-fade-in-up">
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">
</div>
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400"></div>
<div className="mt-4 space-y-3">
{CHAT_SCENES.map((scene) => (
<div key={scene.title} className="rounded-2xl border border-border-subtle bg-surface-1/70 px-4 py-3">
@ -137,9 +135,9 @@ export default function ChatPage() {
</svg>
</div>
<div>
<h2 className="text-sm font-semibold"></h2>
<h2 className="text-sm font-semibold">A </h2>
<p className="text-xs text-text-muted">
/ / / 线
/ / / /
</p>
</div>
</div>
@ -163,7 +161,7 @@ export default function ChatPage() {
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />
</svg>
</div>
<h3 className="text-lg font-semibold"></h3>
<h3 className="text-lg font-semibold"></h3>
</div>
<div className="mx-auto mt-8 grid w-full max-w-3xl gap-3 md:grid-cols-2">
@ -230,7 +228,7 @@ export default function ChatPage() {
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={handleKeyDown}
placeholder="比如:结合我的自选股和今日主线,明天我应该先看哪几只?"
placeholder="比如:诊断一下 300750.SZ结合今日主线给触发和失效条件"
rows={1}
disabled={streaming}
className="min-h-[46px] flex-1 resize-none rounded-2xl border border-border-subtle bg-surface-2 px-4 py-3 text-sm transition-all duration-200 placeholder:text-text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent-cyan/30"

View File

@ -153,7 +153,7 @@ export default function DashboardPage() {
setRefreshResult(null);
try {
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},胜率 ${clampPercent(result.win_rate ?? 0).toFixed(1)}%`);
await loadData();
await loadSecondaryData();
setTimeout(() => setRefreshResult(null), 5000);
@ -752,6 +752,11 @@ function buildMarketSummary(
};
}
function clampPercent(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
function buildActionGuides(
strategyProfile: LatestResult["strategy_profile"],
board: StrategyBoard | null,

View File

@ -91,6 +91,7 @@ export default function StrategyPage() {
}, [authLoading, loadData, user]);
const diagnosis = useMemo(() => buildCalibrationDiagnosis(iteration, performance), [iteration, performance]);
const safeWinRate = clampPercent(performance?.win_rate ?? 0);
if (authLoading || user?.role !== "admin" || loading) {
return (
@ -137,7 +138,7 @@ export default function StrategyPage() {
<div className="grid grid-cols-2 gap-2 self-start">
<MetricCard label="复盘样本" value={iteration?.sample_size ?? 0} />
<MetricCard label="整体胜率" value={`${(performance?.win_rate ?? 0).toFixed(1)}%`} tone={(performance?.win_rate ?? 0) >= 50 ? "up" : "down"} />
<MetricCard label="整体胜率" value={`${safeWinRate.toFixed(1)}%`} tone={safeWinRate >= 50 ? "up" : "down"} />
<MetricCard label="平均收益" value={`${(performance?.avg_return ?? 0) > 0 ? "+" : ""}${(performance?.avg_return ?? 0).toFixed(2)}%`} tone={(performance?.avg_return ?? 0) >= 0 ? "up" : "down"} />
<MetricCard label="平均回撤" value={`${(performance?.avg_max_drawdown ?? 0).toFixed(2)}%`} tone="down" />
<MetricFact label="已跟踪" value={`${performance?.tracked ?? 0}`} />
@ -238,7 +239,7 @@ function buildCalibrationDiagnosis(
iteration: StrategyIterationReport | null,
performance: PerformanceStats | null
) {
const winRate = performance?.win_rate ?? 0;
const winRate = clampPercent(performance?.win_rate ?? 0);
const avgReturn = performance?.avg_return ?? 0;
const tracked = performance?.tracked ?? 0;
const headline =
@ -269,6 +270,11 @@ function buildCalibrationDiagnosis(
return { headline, detail, useFor, notFor };
}
function clampPercent(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
function DecisionList({
title,
items,

View File

@ -118,8 +118,8 @@ export function SidebarNav() {
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" />
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" />
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" />
<SideNavItem href="/chat" icon={<ChatIcon />} label="系统智能体" />
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="个股诊断" />
<SideNavItem href="/chat" icon={<ChatIcon />} label="作战问答" />
{user?.role === "admin" && (
<>
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="系统校准" />
@ -159,6 +159,9 @@ export function MobileBottomNav() {
<MobileNavItem href="/recommendations" label="推荐池">
<TargetIcon />
</MobileNavItem>
<MobileNavItem href="/chat" label="智能体">
<ChatIcon />
</MobileNavItem>
<MobileNavItem href="/watchlists" label="自选">
<WatchlistIcon />
</MobileNavItem>