diff --git a/backend/app/engine/recommender.py b/backend/app/engine/recommender.py index 1fd7f717..3bb55663 100644 --- a/backend/app/engine/recommender.py +++ b/backend/app/engine/recommender.py @@ -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 { diff --git a/backend/app/llm/chat_agent.py b/backend/app/llm/chat_agent.py index 7adc9458..7942a537 100644 --- a/backend/app/llm/chat_agent.py +++ b/backend/app/llm/chat_agent.py @@ -27,6 +27,7 @@ TOOL_LABELS = { "get_user_watchlist_snapshot": "读取自选股作战池", "get_stock_kline": "查询K线数据", "get_stock_capital_flow": "查询资金流向", + "diagnose_stock": "生成个股会诊", "search_stock": "搜索股票", } diff --git a/backend/app/llm/prompts.py b/backend/app/llm/prompts.py index 76b3f5fc..a8ab7960 100644 --- a/backend/app/llm/prompts.py +++ b/backend/app/llm/prompts.py @@ -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_board、get_latest_recommendations、get_user_watchlist_snapshot +- 回答用户关于某只股票能不能看、是否该买、持仓怎么处理、为什么涨跌、是否要复盘时,必须先 search_stock(如果用户没给标准 ts_code),再调用 diagnose_stock;必要时补充 get_stock_capital_flow、get_stock_technical_signal - 盘中时段(9:30-15:00)必须使用实时数据,盘后时段使用当日收盘或最近一次系统生成的数据 - 不要脱离系统上下文泛泛而谈,必须先调用工具获取最新结果再回答 +- A 股优先看资金顺势、主线板块、量价承接和位置;RSI/MACD/KDJ 只做节奏与风控确认,不能因超买超卖本身直接否决或买入 回答要求: 1. 使用工具获取最新数据后再回答,不要凭空编造数据 diff --git a/backend/app/llm/tool_executor.py b/backend/app/llm/tool_executor.py index 9f5c9243..51ebb499 100644 --- a/backend/app/llm/tool_executor.py +++ b/backend/app/llm/tool_executor.py @@ -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 股优先看资金顺势、主线板块、量价承接和位置;技术指标只做节奏与风控确认 +- RSI、MACD、KDJ 的超买超卖不能单独决定买卖 +- 不写传统研报,不堆原始数据,不承诺收益 +- 用 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 diff --git a/backend/app/llm/tools.py b/backend/app/llm/tools.py index c7d2c0bd..6a7b73af 100644 --- a/backend/app/llm/tools.py +++ b/backend/app/llm/tools.py @@ -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": { diff --git a/frontend/src/app/(auth)/chat/page.tsx b/frontend/src/app/(auth)/chat/page.tsx index f0394e5a..6ca5d941 100644 --- a/frontend/src/app/(auth)/chat/page.tsx +++ b/frontend/src/app/(auth)/chat/page.tsx @@ -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() {