From b699b185fca42da0081618071a0c26cb249ba632 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Wed, 22 Apr 2026 11:02:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=80=E5=A4=A7=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/__pycache__/main.cpython-313.pyc | Bin 4835 -> 4930 bytes .../app/api/__pycache__/chat.cpython-313.pyc | Bin 2698 -> 2814 bytes .../api/__pycache__/market.cpython-313.pyc | Bin 5613 -> 11547 bytes .../recommendations.cpython-313.pyc | Bin 7872 -> 8709 bytes .../api/__pycache__/stocks.cpython-313.pyc | Bin 23428 -> 32329 bytes backend/app/api/chat.py | 7 +- backend/app/api/market.py | 111 ++- backend/app/api/recommendations.py | 15 +- backend/app/api/stocks.py | 274 +++++- backend/app/api/watchlists.py | 250 ++++++ .../data/__pycache__/models.cpython-313.pyc | Bin 6601 -> 8465 bytes backend/app/data/models.py | 39 + .../db/__pycache__/database.cpython-313.pyc | Bin 3676 -> 5870 bytes .../app/db/__pycache__/tables.cpython-313.pyc | Bin 4417 -> 6552 bytes backend/app/db/database.py | 30 + backend/app/db/tables.py | 49 ++ .../__pycache__/recommender.cpython-313.pyc | Bin 27874 -> 38175 bytes .../__pycache__/scheduler.cpython-313.pyc | Bin 6036 -> 7169 bytes .../__pycache__/screener.cpython-313.pyc | Bin 53552 -> 58246 bytes backend/app/engine/recommender.py | 284 +++++- backend/app/engine/scheduler.py | 18 + backend/app/engine/screener.py | 141 ++- backend/app/engine/watchlist.py | 239 +++++ .../__pycache__/chat_agent.cpython-313.pyc | Bin 4145 -> 4515 bytes .../llm/__pycache__/enhancer.cpython-313.pyc | Bin 7396 -> 807 bytes .../llm/__pycache__/prompts.cpython-313.pyc | Bin 5966 -> 6690 bytes .../__pycache__/tool_executor.cpython-313.pyc | Bin 12377 -> 16764 bytes .../app/llm/__pycache__/tools.cpython-313.pyc | Bin 2679 -> 3022 bytes backend/app/llm/chat_agent.py | 116 +-- backend/app/llm/daily_review.py | 90 +- backend/app/llm/prompts.py | 26 +- backend/app/llm/strategy_board.py | 268 ++++++ backend/app/llm/strategy_iteration.py | 286 ++++++ backend/app/llm/strategy_selector.py | 211 +++++ backend/app/llm/tool_executor.py | 91 +- backend/app/llm/tools.py | 24 + backend/app/main.py | 3 +- backend/astock.db | Bin 2797568 -> 2797568 bytes frontend/.next/app-build-manifest.json | 57 +- frontend/.next/build-manifest.json | 15 +- frontend/.next/react-loadable-manifest.json | 21 +- frontend/.next/server/app-paths-manifest.json | 6 +- .../interception-route-rewrite-manifest.js | 2 +- .../.next/server/middleware-build-manifest.js | 15 +- .../middleware-react-loadable-manifest.js | 2 +- frontend/.next/server/pages-manifest.json | 6 +- .../server/server-reference-manifest.json | 2 +- frontend/src/app/(auth)/chat/page.tsx | 334 ++++--- frontend/src/app/(auth)/dashboard/page.tsx | 403 ++++++--- frontend/src/app/(auth)/diagnose/page.tsx | 484 +++++++---- frontend/src/app/(auth)/layout.tsx | 4 +- .../src/app/(auth)/recommendations/page.tsx | 354 +++++++- frontend/src/app/(auth)/sectors/page.tsx | 341 ++++++-- frontend/src/app/(auth)/stock/[code]/page.tsx | 818 ++++++++++-------- frontend/src/app/(auth)/strategy/page.tsx | 196 +++++ frontend/src/app/(auth)/watchlists/page.tsx | 666 ++++++++++++++ frontend/src/app/(public)/page.tsx | 399 ++------- frontend/src/components/market-temp.tsx | 5 +- frontend/src/components/nav.tsx | 41 +- frontend/src/components/sector-heatmap.tsx | 7 +- frontend/src/components/stock-card.tsx | 281 +++--- frontend/src/lib/api.ts | 233 +++++ 62 files changed, 5717 insertions(+), 1547 deletions(-) create mode 100644 backend/app/api/watchlists.py create mode 100644 backend/app/engine/watchlist.py create mode 100644 backend/app/llm/strategy_board.py create mode 100644 backend/app/llm/strategy_iteration.py create mode 100644 backend/app/llm/strategy_selector.py create mode 100644 frontend/src/app/(auth)/strategy/page.tsx create mode 100644 frontend/src/app/(auth)/watchlists/page.tsx diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc index 5ece7efa056694d7202d523fc338ae919e09ab8a..419c8fd453a3eae891c614d00d7e73204bb12750 100644 GIT binary patch delta 797 zcmaKqF>KR76o&6&hr}+99lJ@Kpr)~vLQ5fnga`_ViMApt3i1sV?JaKOC_!!;ol~(Q zF)&-VFdCfUtpP?+B7$nnqw+h&N6?tYMzeN1~}tv z@P{(ij7IWC$ihYXPWT8Vx+*Tq@=LeB+wr?Wf4DCe#m6}|A!0H)R^48&(%PxC+`hZx z1|G@ts4|JNnC}Ioa2DG=AYR2i?03i&`YoA*^K_JamN{z|RY&QJKb3q~9e$P^;lWMr zSK*T2Qst85A~9Klq{fbbZ%B#UW_q0TyKOJ%x%;F(Tvx6`SRazq4X8)(QpaIJJ<&E6c?IFiMAo>Btsd?jv<`jm5!q%j meuv;K-Avnw_7Q0R1a0~%{YSokWR$-fy5`9nP)&BtG+?GuM delta 769 zcmaJfjcLv+JW5g&VKKG_uk3=F#qF>{!7;|$o>>bx(PpRN%xi2(GV+>WJU4~-}E!2>}M}4%qF&XeIy^*h{_AB@zgB7f?Uz| zZXFRvkHqk9<3VEfKdL!a&5TvEW7YgvwJ=m=-g#fnLtGJTyrAZBPo9Jveugp^@5GmT zFBjB>dx^#AZWRwEr}2;c3{tqKoa?_;W)xE3S7oO-4AMcn?KC%?X3z;XgNU-zg4IVX z=Rz8>;vv`AWz-3}offkMr_#r?(e2Pd@==R2I)DZ%p`+c+9|pwd#Ro78Q468=dcu>h zEb(b}UVti~CLkq1RAg_BIp0(I6FWZ_%h7JYa7BxqcSF?Z2B^f=n!)Xwe|# zm2Pt92L4W8f+{*j0CV`l*ubC0vJuC{Auji@6?Dw?s<5*)0XGB;bFS{}HoGCc#_F6C b1)`_;(yXZUuTuSsRL76zU;LJ7C}QLvrM#2x diff --git a/backend/app/api/__pycache__/chat.cpython-313.pyc b/backend/app/api/__pycache__/chat.cpython-313.pyc index f653c2c4fc5487343a31880f08f9e31b08077974..5acba5d36c57aba2edd40ba5561a624ba50409da 100644 GIT binary patch delta 1366 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 diff --git a/backend/app/api/__pycache__/recommendations.cpython-313.pyc b/backend/app/api/__pycache__/recommendations.cpython-313.pyc index fc023d392a13b8c230fe3e1d70dda7b855766a19..c6cbb806faf59927bd81ea6165c2f49eb987ad06 100644 GIT binary patch delta 2906 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 XHLe-!iab5+e}r4(=z0IF21@!LpAz2R diff --git a/backend/app/api/__pycache__/stocks.cpython-313.pyc b/backend/app/api/__pycache__/stocks.cpython-313.pyc index ad2a240e98b6c451966259870e207c253edc3c77..a740dfb120a8b2b227953a98f896a14351453d40 100644 GIT binary patch delta 14928 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 Toh-P+P(T!y-HtP52ekJ83D#XT diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 4136a698..9707c7f8 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -6,10 +6,11 @@ POST /api/chat/stream - SSE 流式对话 import json import logging -from fastapi import APIRouter +from fastapi import APIRouter, Depends from fastapi.responses import StreamingResponse from pydantic import BaseModel +from app.core.deps import get_current_user from app.llm.chat_agent import chat_stream logger = logging.getLogger(__name__) @@ -26,13 +27,13 @@ class ChatRequest(BaseModel): @router.post("/stream") -async def chat_stream_endpoint(req: ChatRequest): +async def chat_stream_endpoint(req: ChatRequest, current_user: dict = Depends(get_current_user)): """流式对话接口(SSE)""" messages = [{"role": m.role, "content": m.content} for m in req.messages] async def event_generator(): try: - async for msg in chat_stream(messages): + async for msg in chat_stream(messages, current_user=current_user): data = json.dumps(msg, ensure_ascii=False) yield f"data: {data}\n\n" yield "data: [DONE]\n\n" diff --git a/backend/app/api/market.py b/backend/app/api/market.py index 0948e89b..e7d2024a 100644 --- a/backend/app/api/market.py +++ b/backend/app/api/market.py @@ -1,11 +1,14 @@ """市场概览 API""" -from fastapi import APIRouter +from datetime import datetime + +from fastapi import APIRouter, Depends from app.data.tushare_client import tushare_client from app.data import tencent_client from app.engine.recommender import get_latest_recommendations from app.config import is_trading_hours, is_market_session +from app.core.deps import get_current_admin router = APIRouter(prefix="/api/market", tags=["market"]) @@ -73,8 +76,112 @@ async def get_daily_review(): return {"reviews": reviews} +@router.get("/strategy-board") +async def get_strategy_board(): + """获取今日市场作战面板(只读,不触发 LLM)""" + from app.llm.strategy_board import build_strategy_board + return await build_strategy_board(include_llm=False) + + +@router.get("/strategy-iteration") +async def get_strategy_iteration(limit: int = 50): + """获取策略复盘迭代建议(只读,不触发 LLM)""" + from app.llm.strategy_iteration import build_strategy_iteration_report + return await build_strategy_iteration_report(limit=limit, include_llm=False) + + +@router.get("/ops-status") +async def get_ops_status(): + """管理员任务中心状态与数据新鲜度(只读,不触发扫描或 LLM)。""" + from sqlalchemy import text + from app.db.database import get_db + from app.engine.recommender import _scan_running + + async with get_db() as db: + rec_row = (await db.execute( + text( + "SELECT created_at FROM recommendations " + "ORDER BY created_at DESC LIMIT 1" + ) + )).fetchone() + tracking_row = (await db.execute( + text( + "SELECT track_date, created_at FROM recommendation_tracking " + "ORDER BY track_date DESC, id DESC LIMIT 1" + ) + )).fetchone() + market_row = (await db.execute( + text( + "SELECT trade_date, created_at FROM market_temperature " + "ORDER BY REPLACE(trade_date, '-', '') DESC, id DESC LIMIT 1" + ) + )).fetchone() + sector_row = (await db.execute( + text( + "SELECT trade_date, created_at FROM sector_heat " + "ORDER BY REPLACE(trade_date, '-', '') DESC, id DESC LIMIT 1" + ) + )).fetchone() + board_row = (await db.execute( + text( + "SELECT created_at FROM daily_reviews " + "ORDER BY trade_date DESC LIMIT 1" + ) + )).fetchone() + + def _fmt_dt(value): + return str(value or "") + + latest_market_date = str(market_row._mapping["trade_date"]) if market_row else "" + latest_sector_date = str(sector_row._mapping["trade_date"]) if sector_row else "" + latest_tracking_date = str(tracking_row._mapping["track_date"]) if tracking_row else "" + + return { + "scan_running": _scan_running, + "scan_mode": "intraday" if is_trading_hours() else "post_market", + "is_trading": is_trading_hours(), + "data_freshness": { + "market_trade_date": latest_market_date, + "sector_trade_date": latest_sector_date, + "tracking_trade_date": latest_tracking_date, + "last_recommendation_created_at": _fmt_dt(rec_row._mapping["created_at"]) if rec_row else "", + "last_tracking_created_at": _fmt_dt(tracking_row._mapping["created_at"]) if tracking_row else "", + "last_market_created_at": _fmt_dt(market_row._mapping["created_at"]) if market_row else "", + "last_sector_created_at": _fmt_dt(sector_row._mapping["created_at"]) if sector_row else "", + "last_review_created_at": _fmt_dt(board_row._mapping["created_at"]) if board_row else "", + "status": "fresh" if latest_market_date else "empty", + "message": ( + f"最新市场日期 {latest_market_date},最近跟踪 {latest_tracking_date or '暂无'}" + if latest_market_date else + "暂无市场缓存数据,请由管理员触发扫描。" + ), + "generated_at": datetime.now().isoformat(), + }, + "actions": [ + {"key": "refresh", "label": "立即扫描", "admin_only": True}, + {"key": "update_tracking", "label": "更新跟踪", "admin_only": True}, + {"key": "generate_strategy_board", "label": "生成策略板", "admin_only": True}, + {"key": "generate_strategy_iteration", "label": "生成策略复盘", "admin_only": True}, + ], + } + + +@router.post("/generate-strategy-board") +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) + + +@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) + + @router.post("/generate-review") -async def generate_daily_review(): +async def generate_daily_review(_admin: dict = Depends(get_current_admin)): """手动触发生成每日复盘""" from app.llm.daily_review import generate_review result = await generate_review() diff --git a/backend/app/api/recommendations.py b/backend/app/api/recommendations.py index 70acb6d9..5d9ad2a1 100644 --- a/backend/app/api/recommendations.py +++ b/backend/app/api/recommendations.py @@ -4,7 +4,7 @@ import asyncio import logging import traceback from datetime import datetime -from fastapi import APIRouter +from fastapi import APIRouter, Depends from app.engine.recommender import ( refresh_recommendations, @@ -13,6 +13,7 @@ from app.engine.recommender import ( get_performance_stats, ) from app.config import is_trading_hours +from app.core.deps import get_current_admin logger = logging.getLogger(__name__) @@ -60,6 +61,13 @@ async def get_latest(): "risk_note": r.risk_note, "llm_analysis": r.llm_analysis, "entry_timing": r.entry_timing, + "action_plan": r.action_plan, + "trigger_condition": r.trigger_condition, + "invalidation_condition": r.invalidation_condition, + "suggested_position_pct": r.suggested_position_pct, + "review_after_days": r.review_after_days, + "lifecycle_status": r.lifecycle_status, + "data_freshness": r.data_freshness, "llm_score": r.llm_score, "strategy": r.strategy, "entry_signal_type": r.entry_signal_type, @@ -69,11 +77,12 @@ async def get_latest(): for r in result.get("recommendations", []) ], "scan_mode": result.get("scan_mode", "unknown"), + "strategy_profile": result.get("strategy_profile"), } @router.post("/refresh") -async def refresh(scan_session: str = "manual"): +async def refresh(scan_session: str = "manual", _admin: dict = Depends(get_current_admin)): """手动触发一次全量筛选(后台执行,立即返回)""" from app.engine.recommender import _scan_running, _scan_lock @@ -126,7 +135,7 @@ async def _run_scan_background(scan_session: str): @router.post("/update-tracking") -async def update_tracking(): +async def update_tracking(_admin: dict = Depends(get_current_admin)): """独立更新推荐跟踪数据(不触发新扫描,盘中可单独调用)""" from app.engine.recommender import _update_tracking await _update_tracking() diff --git a/backend/app/api/stocks.py b/backend/app/api/stocks.py index 3c073c25..24f68413 100644 --- a/backend/app/api/stocks.py +++ b/backend/app/api/stocks.py @@ -5,7 +5,7 @@ import logging import traceback from datetime import datetime, timedelta -from fastapi import APIRouter +from fastapi import APIRouter, Query from starlette.responses import StreamingResponse from app.data.tushare_client import tushare_client @@ -19,6 +19,188 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/stocks", tags=["stocks"]) +@router.get("/search") +async def search_stock(keyword: str): + """搜索股票""" + basic = tushare_client.get_stock_basic() + if basic.empty: + return [] + matches = basic[ + basic["name"].str.contains(keyword, na=False) | + basic["ts_code"].str.contains(keyword, na=False) | + basic["symbol"].str.contains(keyword, na=False) + ].head(20) + return matches[["ts_code", "name", "industry"]].to_dict(orient="records") + + +@router.get("/{ts_code}/thesis") +async def get_stock_thesis(ts_code: str): + """获取个股推荐推演归档(只读缓存,不触发扫描或 LLM)。""" + from sqlalchemy import text + + async with get_db() as db: + rec_result = await db.execute( + text( + "SELECT * FROM recommendations " + "WHERE ts_code = :code " + "ORDER BY created_at DESC, id DESC LIMIT 1" + ), + {"code": ts_code}, + ) + rec_row = rec_result.fetchone() + + tracking_rows = [] + diagnosis_rows = [] + if rec_row: + rec_id = rec_row._mapping["id"] + tracking_result = await db.execute( + text( + "SELECT * FROM recommendation_tracking " + "WHERE recommendation_id = :rid " + "ORDER BY track_date DESC, id DESC LIMIT 10" + ), + {"rid": rec_id}, + ) + tracking_rows = tracking_result.fetchall() + + diagnosis_result = await db.execute( + text( + "SELECT id, diagnosis, created_at FROM stock_diagnoses " + "WHERE ts_code = :code " + "ORDER BY created_at DESC LIMIT 3" + ), + {"code": ts_code}, + ) + diagnosis_rows = diagnosis_result.fetchall() + + if not rec_row: + return { + "ts_code": ts_code, + "name": ts_code, + "has_recommendation": False, + "recommendation": None, + "latest_tracking": None, + "tracking_history": [], + "diagnoses": [ + { + "id": row._mapping["id"], + "diagnosis": row._mapping["diagnosis"] or "", + "created_at": str(row._mapping["created_at"] or ""), + } + for row in diagnosis_rows + ], + "decision_points": [], + "data_freshness": { + "recommendation_created_at": "", + "tracking_date": "", + "status": "no_recommendation", + "message": "暂无推荐归档,可从 AI 诊断页生成个股诊断。", + }, + } + + r = rec_row._mapping + + def _safe_json_list(value: str | None) -> list: + if not value: + return [] + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, list) else [] + except Exception: + return [] + + tracking_history = [] + for row in tracking_rows: + t = row._mapping + tracking_history.append({ + "track_date": t["track_date"], + "current_price": t["current_price"], + "pct_from_entry": t["pct_from_entry"], + "max_return_pct": t["max_return_pct"], + "max_drawdown_pct": t["max_drawdown_pct"], + "days_since_recommendation": t["days_since_recommendation"], + "hit_target": bool(t["hit_target"]), + "hit_stop_loss": bool(t["hit_stop_loss"]), + "close_reason": t["close_reason"] or "", + "review_note": t["review_note"] or "", + "status": t["status"] or "", + }) + latest_tracking = tracking_history[0] if tracking_history else None + + decision_points = [ + {"label": "操作计划", "value": r["action_plan"] or "观察"}, + {"label": "触发条件", "value": r["trigger_condition"] or "等待触发条件归档"}, + {"label": "失效条件", "value": r["invalidation_condition"] or "等待失效条件归档"}, + {"label": "建议仓位", "value": f"{r['suggested_position_pct']}%" if r["suggested_position_pct"] is not None else "未设置"}, + {"label": "复盘周期", "value": f"{r['review_after_days'] or 3}个交易日"}, + ] + + freshness_status = "fresh" + freshness_message = "推荐归档可用" + if not latest_tracking: + freshness_status = "needs_tracking" + freshness_message = "暂无跟踪记录,建议管理员执行跟踪更新。" + elif latest_tracking.get("track_date"): + freshness_message = f"最近跟踪日期 {latest_tracking['track_date']}" + + return { + "ts_code": r["ts_code"], + "name": r["name"], + "has_recommendation": True, + "recommendation": { + "id": r["id"], + "ts_code": r["ts_code"], + "name": r["name"], + "sector": r["sector"] or "", + "score": r["score"] or 0, + "market_temp_score": r["market_temp_score"] or 0, + "sector_score": r["sector_score"] or 0, + "capital_score": r["capital_score"] or 0, + "technical_score": r["technical_score"] or 0, + "supply_demand_score": r["supply_demand_score"] or 0, + "price_action_score": r["price_action_score"] or 0, + "position_score": r["position_score"] or 50, + "valuation_score": r["valuation_score"] or 50, + "entry_price": r["entry_price"], + "target_price": r["target_price"], + "stop_loss": r["stop_loss"], + "reasons": _safe_json_list(r["reasons"]), + "risk_note": r["risk_note"] or "", + "action_plan": r["action_plan"] or "观察", + "trigger_condition": r["trigger_condition"] or "", + "invalidation_condition": r["invalidation_condition"] or "", + "suggested_position_pct": r["suggested_position_pct"] or 0, + "review_after_days": r["review_after_days"] or 3, + "lifecycle_status": r["lifecycle_status"] or "candidate", + "data_freshness": r["data_freshness"] or "", + "llm_analysis": r["llm_analysis"] or "", + "llm_score": r["llm_score"], + "strategy": r["strategy"] or "trend_breakout", + "entry_signal_type": r["entry_signal_type"] or "none", + "entry_timing": r["entry_timing"] or "", + "scan_session": r["scan_session"] or "", + "created_at": str(r["created_at"] or ""), + }, + "latest_tracking": latest_tracking, + "tracking_history": tracking_history, + "diagnoses": [ + { + "id": row._mapping["id"], + "diagnosis": row._mapping["diagnosis"] or "", + "created_at": str(row._mapping["created_at"] or ""), + } + for row in diagnosis_rows + ], + "decision_points": decision_points, + "data_freshness": { + "recommendation_created_at": str(r["created_at"] or ""), + "tracking_date": latest_tracking["track_date"] if latest_tracking else "", + "status": freshness_status, + "message": freshness_message, + }, + } + + @router.get("/{ts_code}/quote") async def get_quote(ts_code: str): """获取个股实时行情""" @@ -86,20 +268,6 @@ async def get_capital_flow(ts_code: str, days: int = 10): return records -@router.get("/search") -async def search_stock(keyword: str): - """搜索股票""" - basic = tushare_client.get_stock_basic() - if basic.empty: - return [] - matches = basic[ - basic["name"].str.contains(keyword, na=False) | - basic["ts_code"].str.contains(keyword, na=False) | - basic["symbol"].str.contains(keyword, na=False) - ].head(20) - return matches[["ts_code", "name", "industry"]].to_dict(orient="records") - - @router.get("/{ts_code}/diagnose/history") async def get_diagnose_history(ts_code: str): """获取个股最近5次诊断历史""" @@ -123,6 +291,7 @@ async def get_diagnose_history(ts_code: str): "id": r["id"], "ts_code": r["ts_code"], "name": r["name"], + "diagnosis_mode": r.get("diagnosis_mode", "entry"), "diagnosis": r["diagnosis"], "created_at": str(r["created_at"]), }) @@ -133,7 +302,7 @@ async def get_diagnose_history(ts_code: str): @router.post("/{ts_code}/diagnose") -async def diagnose_stock(ts_code: str): +async def diagnose_stock(ts_code: str, mode: str = Query("entry")): """AI 诊断个股(SSE 流式返回)""" from app.config import settings if not settings.deepseek_api_key: @@ -150,10 +319,11 @@ async def diagnose_stock(ts_code: str): "SELECT id, ts_code, name, diagnosis, created_at " "FROM stock_diagnoses " "WHERE ts_code = :code " + "AND diagnosis_mode = :mode " "AND created_at >= datetime('now', '-30 minutes', 'localtime') " "ORDER BY created_at DESC LIMIT 1" ), - {"code": ts_code}, + {"code": ts_code, "mode": mode}, ) recent_row = result.fetchone() if recent_row: @@ -342,7 +512,25 @@ async def diagnose_stock(ts_code: str): except Exception: pass - user_msg = f"""请对以下A股进行全面诊断分析: + mode_instruction_map = { + "entry": "这是建仓前诊断。重点判断是否值得纳入操作或重点关注,强调触发条件和失效条件。", + "holding": "这是持仓复核。重点判断原有逻辑是否仍成立,是否该继续持有、减仓或退出。", + "review": "这是回撤复盘。重点分析问题出在个股、板块还是市场环境,并给出修正建议。", + "tracking": "这是继续跟踪。重点判断是否保留在观察池、何时升级为可操作或何时移除。", + } + mode_label_map = { + "entry": "建仓前诊断", + "holding": "持仓复核", + "review": "回撤复盘", + "tracking": "继续跟踪", + } + mode_instruction = mode_instruction_map.get(mode, mode_instruction_map["entry"]) + mode_label = mode_label_map.get(mode, mode_label_map["entry"]) + + user_msg = f"""请基于当前 AI 推荐体系,对以下A股进行结构化个股会诊: + +诊断模式: {mode_label} +模式要求: {mode_instruction} 股票: {ts_code} ({basic_info}) {quote_str} @@ -358,23 +546,44 @@ async def diagnose_stock(ts_code: str): {sector_str} 重要提示: -1. 趋势评分是推荐体系的技术面核心分数(均线排列40+高低点结构35+MA20方向25=满分100),辅助信号计数仅供参考不参与主评分。 -2. 位置安全评分高(>80)表示股价处于相对低位,低(<40)表示可能追高。 -3. 如果有推荐体系评分,请作为主要分析依据;趋势评分和信号计数从不同维度描述技术面状态。 +1. 你不是在写传统研报,而是在给交易作战台输出结构化会诊意见。 +2. 如果有推荐体系评分、操作计划、跟踪信息,请优先沿用当前推荐体系,而不是另起一套标准。 +3. 趋势评分是推荐体系的技术面核心分数(均线排列40+高低点结构35+MA20方向25=满分100),辅助信号计数仅供参考不参与主评分。 +4. 位置安全评分高(>80)表示股价处于相对低位,低(<40)表示可能追高。 +5. 板块信息和推荐体系信息优先级高于单一技术指标。 {freshness_note} -请从以下维度分析(Markdown格式,简洁专业): -## 综合评级 -(给出1-5星评级和一句话总结,综合趋势评分、位置安全和供需形态) +请严格按以下 Markdown 结构输出,不要写成泛泛长文: -## 技术面分析 -(趋势方向、均线关系、支撑压力、量价配合,优先参考趋势评分而非信号计数) +## 当前结论 +- 结论: 只能从「可操作 / 重点关注 / 观察 / 回避」中选一个 +- 一句话判断: 用一句话解释为什么 +- 适配模式: 说明更适合启动试错、分歧回流、趋势跟随还是只观察 -## 资金面分析 -(主力资金态度、板块联动效应) +## 核心逻辑 +- 市场环境: 当前大盘和风格是否支持这只票 +- 板块位置: 所属板块是主线、次主线还是观察线 +- 个股角色: 龙头 / 跟风 / 独立逻辑 / 非核心 -## 操作建议 -(适合什么类型的投资者、入场时机、风险提示)""" +## 执行动作 +- 触发条件: 什么情况下才可以行动 +- 失效条件: 什么情况下放弃 +- 仓位建议: 用低 / 中 / 高 或百分比表达 +- 适合谁: 适合激进试错、低吸等待、还是不适合参与 + +## 风险清单 +- 风险1: +- 风险2: +- 风险3: + +## 复盘问题 +- 如果后续走势不符合预期,优先检查哪两个问题 + +要求: +- 结论必须明确,不能模糊两可 +- 少写形容词,多写交易判断 +- 不要重复原始数据 +- 文字保持简洁,避免旧式研报语气""" # ── SSE 流式返回 ── async def _stream_diagnosis(): @@ -384,7 +593,7 @@ async def diagnose_stock(ts_code: str): stream = await client.chat.completions.create( model=settings.deepseek_model, messages=[ - {"role": "system", "content": "你是一位专业的A股分析师,擅长技术面和资金面分析。回复使用Markdown格式,简洁专业,客观理性。"}, + {"role": "system", "content": "你是A股AI投研作战台中的个股会诊模块。你的职责不是写传统长文研报,而是基于市场环境、板块地位、推荐体系评分和跟踪结果,输出可执行、结构化的交易会诊意见。回复必须使用Markdown,结论明确,强调触发条件、失效条件、仓位和风险。"}, {"role": "user", "content": user_msg}, ], max_tokens=1500, @@ -407,6 +616,7 @@ async def diagnose_stock(ts_code: str): tables.stock_diagnoses_table.insert().values( ts_code=ts_code, name=stock_name or ts_code, + diagnosis_mode=mode, diagnosis=full_content, ) ) @@ -425,4 +635,4 @@ async def diagnose_stock(ts_code: str): yield f"data: {json.dumps({'error': error_msg}, ensure_ascii=False)}\n\n" yield f"data: {json.dumps({'done': True, 'ts_code': ts_code}, ensure_ascii=False)}\n\n" - return StreamingResponse(_stream_diagnosis(), media_type="text/event-stream") \ No newline at end of file + return StreamingResponse(_stream_diagnosis(), media_type="text/event-stream") diff --git a/backend/app/api/watchlists.py b/backend/app/api/watchlists.py new file mode 100644 index 00000000..4743a5a0 --- /dev/null +++ b/backend/app/api/watchlists.py @@ -0,0 +1,250 @@ +"""用户自选股 API""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import text + +from app.core.deps import get_current_user +from app.db.database import get_db +from app.engine.watchlist import analyze_watchlist_for_all_users, analyze_watchlist_item + +router = APIRouter(prefix="/api/watchlists", tags=["watchlists"]) + + +class WatchlistCreateRequest(BaseModel): + ts_code: str + name: str + note: str = "" + watch_group: str = "observe" + cost_price: float | None = None + + +class WatchlistUpdateRequest(BaseModel): + note: str | None = None + watch_group: str | None = None + cost_price: float | None = None + + +WATCH_GROUPS = {"observe", "focus", "candidate", "holding"} + + +@router.get("") +async def list_watchlists(current_user: dict = Depends(get_current_user)): + async with get_db() as db: + rows = (await db.execute( + text( + "SELECT w.id, w.ts_code, w.name, w.note, w.watch_group, w.cost_price, w.created_at, " + "a.conclusion, a.advice, a.trigger_condition, a.risk_note, a.summary, a.created_at AS analysis_created_at " + "FROM user_watchlists w " + "LEFT JOIN watchlist_analyses a ON a.id = (" + " SELECT id FROM watchlist_analyses " + " WHERE watchlist_id = w.id ORDER BY created_at DESC, id DESC LIMIT 1" + ") " + "WHERE w.user_id = :uid AND COALESCE(w.is_active, 1) = 1 " + "ORDER BY w.created_at DESC" + ), + {"uid": current_user["id"]}, + )).fetchall() + + return [dict(row._mapping) for row in rows] + + +@router.post("") +async def create_watchlist(req: WatchlistCreateRequest, current_user: dict = Depends(get_current_user)): + normalized_code = req.ts_code.strip().upper() + normalized_name = req.name.strip() + normalized_note = req.note.strip() + normalized_group = (req.watch_group or "observe").strip().lower() + normalized_cost = req.cost_price if req.cost_price and req.cost_price > 0 else None + + if not normalized_code or not normalized_name: + raise HTTPException(status_code=400, detail="股票代码和名称不能为空") + if normalized_group not in WATCH_GROUPS: + raise HTTPException(status_code=400, detail="无效的自选分组") + + async with get_db() as db: + exists = (await db.execute( + text( + "SELECT id FROM user_watchlists " + "WHERE user_id = :uid AND ts_code = :code AND COALESCE(is_active, 1) = 1" + ), + {"uid": current_user["id"], "code": normalized_code}, + )).fetchone() + if exists: + raise HTTPException(status_code=400, detail="该股票已在自选列表中") + + result = await db.execute( + text( + "INSERT INTO user_watchlists (user_id, ts_code, name, note, watch_group, cost_price, is_active) " + "VALUES (:uid, :code, :name, :note, :watch_group, :cost_price, 1)" + ), + { + "uid": current_user["id"], + "code": normalized_code, + "name": normalized_name, + "note": normalized_note, + "watch_group": normalized_group, + "cost_price": normalized_cost, + }, + ) + await db.commit() + watchlist_id = getattr(result, "lastrowid", None) + + if not watchlist_id: + inserted = (await db.execute( + text( + "SELECT id FROM user_watchlists " + "WHERE user_id = :uid AND ts_code = :code " + "ORDER BY id DESC LIMIT 1" + ), + {"uid": current_user["id"], "code": normalized_code}, + )).fetchone() + if not inserted: + raise HTTPException(status_code=500, detail="自选股创建失败") + watchlist_id = inserted._mapping["id"] + + await analyze_watchlist_item( + watchlist_id=watchlist_id, + user_id=current_user["id"], + ts_code=normalized_code, + name=normalized_name, + note=normalized_note, + watch_group=normalized_group, + cost_price=normalized_cost, + mode="manual", + ) + + return {"status": "ok", "message": "已加入自选并完成首次分析", "watchlist_id": watchlist_id} + + +@router.patch("/{watchlist_id}") +async def update_watchlist(watchlist_id: int, req: WatchlistUpdateRequest, current_user: dict = Depends(get_current_user)): + updates: list[str] = [] + params: dict = {"id": watchlist_id, "uid": current_user["id"]} + + if req.note is not None: + updates.append("note = :note") + params["note"] = req.note.strip() + if req.watch_group is not None: + normalized_group = req.watch_group.strip().lower() + if normalized_group not in WATCH_GROUPS: + raise HTTPException(status_code=400, detail="无效的自选分组") + updates.append("watch_group = :watch_group") + params["watch_group"] = normalized_group + if req.cost_price is not None: + updates.append("cost_price = :cost_price") + params["cost_price"] = req.cost_price if req.cost_price > 0 else None + + if not updates: + raise HTTPException(status_code=400, detail="没有可更新的字段") + + updates.append("updated_at = CURRENT_TIMESTAMP") + + async with get_db() as db: + result = await db.execute( + text( + f"UPDATE user_watchlists SET {', '.join(updates)} " + "WHERE id = :id AND user_id = :uid AND COALESCE(is_active, 1) = 1" + ), + params, + ) + await db.commit() + + if result.rowcount == 0: + raise HTTPException(status_code=404, detail="自选股不存在") + + row = (await db.execute( + text( + "SELECT id, user_id, ts_code, name, note, watch_group, cost_price " + "FROM user_watchlists " + "WHERE id = :id AND user_id = :uid" + ), + {"id": watchlist_id, "uid": current_user["id"]}, + )).fetchone() + + item = row._mapping + return {"status": "ok", "item": dict(item)} + + +@router.delete("/{watchlist_id}") +async def delete_watchlist(watchlist_id: int, current_user: dict = Depends(get_current_user)): + async with get_db() as db: + await db.execute( + text( + "UPDATE user_watchlists SET is_active = 0 " + "WHERE id = :id AND user_id = :uid" + ), + {"id": watchlist_id, "uid": current_user["id"]}, + ) + await db.commit() + return {"status": "ok"} + + +@router.post("/{watchlist_id}/analyze") +async def analyze_single_watchlist(watchlist_id: int, current_user: dict = Depends(get_current_user)): + async with get_db() as db: + row = (await db.execute( + text( + "SELECT id, user_id, ts_code, name, note, watch_group, cost_price FROM user_watchlists " + "WHERE id = :id AND user_id = :uid AND COALESCE(is_active, 1) = 1" + ), + {"id": watchlist_id, "uid": current_user["id"]}, + )).fetchone() + if not row: + raise HTTPException(status_code=404, detail="自选股不存在") + + item = row._mapping + result = await analyze_watchlist_item( + watchlist_id=item["id"], + user_id=item["user_id"], + ts_code=item["ts_code"], + name=item["name"], + note=item["note"] or "", + watch_group=item["watch_group"] or "observe", + cost_price=item["cost_price"], + mode="manual", + ) + return {"status": "ok", "result": result} + + +@router.post("/analyze-all") +async def analyze_all_watchlists(current_user: dict = Depends(get_current_user)): + async with get_db() as db: + rows = (await db.execute( + text( + "SELECT id, user_id, ts_code, name, note, watch_group, cost_price FROM user_watchlists " + "WHERE user_id = :uid AND COALESCE(is_active, 1) = 1" + ), + {"uid": current_user["id"]}, + )).fetchall() + + count = 0 + for row in rows: + item = row._mapping + await analyze_watchlist_item( + watchlist_id=item["id"], + user_id=item["user_id"], + ts_code=item["ts_code"], + name=item["name"], + note=item["note"] or "", + watch_group=item["watch_group"] or "observe", + cost_price=item["cost_price"], + mode="manual", + ) + count += 1 + return {"status": "ok", "count": count, "message": f"已完成 {count} 条自选股分析"} + + +@router.get("/{watchlist_id}/history") +async def watchlist_history(watchlist_id: int, current_user: dict = Depends(get_current_user)): + async with get_db() as db: + rows = (await db.execute( + text( + "SELECT a.* FROM watchlist_analyses a " + "INNER JOIN user_watchlists w ON w.id = a.watchlist_id " + "WHERE a.watchlist_id = :wid AND w.user_id = :uid " + "ORDER BY a.created_at DESC LIMIT 20" + ), + {"wid": watchlist_id, "uid": current_user["id"]}, + )).fetchall() + return [dict(row._mapping) for row in rows] diff --git a/backend/app/data/__pycache__/models.cpython-313.pyc b/backend/app/data/__pycache__/models.cpython-313.pyc index cd9d5d974b5f4acb1320e6118d81b9c38a19ac7b..60b466d92c7018c4a44cc53d0dda3c287ba40101 100644 GIT binary patch delta 1778 zcmYjR-A~(A6t^=cfqao*=aWEy03m#gcI~8gsw%;N7S_Qw_oY%K&p0Nv$OpQ1sL-S( z8q&(kv}E2UG^whpX`1#~Ro%a^hwWulkt)kCd)XJy%l5o;Zag4Tem=k7J@@$BbIeyR$f zM$f5%QY;^Ta(tg>e={@e^wXvwu#FKCEpP?5n8`snF-BsPjXEq&*%)O$k|=nN3r@uy5*+$(1x&HO#d%r(^X!~Ww&}vm_w=7rfh(WYcNhOj}s}?oX?U+{GlgnCB zre5cCy$+tbp%$gxnofPCUBx)T!rBp{?rG{hN!~UfSW({B?S4gukPt?-$HHZ8TUGXz zvMT9@Y}9o-1PijXO;r7ERn>KKGJciKno|7T{D*i;n+R=$j}T0Rj}bmW_!NON7x{$w zZ#?8$Vogut=k<7|`C5zba2abjbIe6sI-27X$6V^@)ebjdO-!P>(%~kp;StAS#7gIm zIiaO=xSW+rH#b_~nl^t)ykag2G1m-Zjh$?!3niiUxrLmG)O_N?kv}WNdd? z-crl9zo_a8(RQ(FWC#{E@?JPk#$4frsUu7n0e9#@fcuQ)4;`#q5#c!}kO{hOUKf(C zZG2_gY=##aez@;GdYSzsK(E_JnV$qpfqc;B|IbEL6{ALaIemtq77=bB+(d98tRgHS zEF-)PU~_nBPPQSEMtvAz1i`Gu2QqX&-8zoi*?xLM5AhD(dVnhk_-_3w0Guu&bU49^ z#F{IImpWX`N+g@>Eq8}Yp0iRzy@C%};lbweVfOuvUL_u2CCpj>wAm2+4IDN%9-NlI zb82}{En1`nhIk6zLZ2Hw-C{qkg4Pe3&(Uc4K=XsfIhx<`3!Kves9j)4un;P^8)DF* z14zXXGI4-}X*yxbA|yiDAZ0umED8DOS+^|JWKul&9QKhsfE}#JWJfh5qL#FZYWs+$ z??`2J58KF@&Mi&WZ5{`$LFdpJJ{(Z5UavraeLGB4rBESZLRwSwV;TUIQI-8C}@J+*u^{7lGoxH)U#V)OOZ z<>SQcGk&haU4%~qd?aAr+u)qB#}&twg4ItCfAuWqMtT32x+rW*~X(C3;ADKAJnuf6r+WEW7U-+zu4|I{5)83xE9` wb0@iZd5q1q?sOS=bZ@dQHriV2GVtj7JS=~d?=tY{4i2!Rt+js`c+iml0X9$IEC2ui delta 221 zcmbQ}bkdmbGcPX}0}z}Je~`IDbRwSwZQU?}B^jzD zQxI|Dd!Xb(uT6CD=Gm9GdxltQ=r|?$Jf;Boovx0|NyV?*5CEB+lSkoHEDE2t50#1s=%eS!& zFOpaRxa#U~$>k8Lxv z9xO}WX$6Os1<-?T^{8F7a80CvAU9y0cJtSq7EXnQEa;TgzO^}_Sm`9xWT#v62@$88 zV4{=O3)a(|#Vxac3JZ!wNUQZnk!Y=JF~j6@f$KJJMLJz>=|5hHe5w{Whbfk35Zd)- z;0pSn%;dK87g2WaIs_5XNLE**Y)?qbCKA@(DTK^ZSB>_>?N$pe?JEYE<|7M gzS#X3iG43<^#>ypXP#*LnXXgcpD{XvSeqAcr7|+=PM*N4 zyg832igEHb;rWcyCx?j`vI|LYb+~^}o7^v=E2IhJd}n76kn6AOs=LWy@KI;-1rZ5G T)6K6${xEXLF^U!m11$mo`A{VO diff --git a/backend/app/db/__pycache__/tables.cpython-313.pyc b/backend/app/db/__pycache__/tables.cpython-313.pyc index 36974dc20152467b7346029d5af7e076f50249ae..c63e2b8b8966efe7a713473953764064803b8d55 100644 GIT binary patch literal 6552 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 delta 1586 zcmZ9L%TE(Q9LIOtX-nC@w(Yj`K}%m)5J71RR9?OSU-(+k%O<1|C5?a)NQ{X|ubgzv zTsEG1B%TO8c=qDSLz}$-Cm(kc@QnM_W(sf#a^StB&ImTmI^gfEamnZ)9X6JH|%0(sFJzK zn%|~e+(C5L>~s&Q*i0gQooJCr-FR2H#H^4#r`><6fCXPY|??MAJi)IP+u z_*3^jqFi}eQl67gHZ-G$)F>P4~89M)*+O(F_L0=8U&uL6*NH>AsmDN=p#M;r~p1%c# z@cF8`)6A7xU4_87x@M9?El!O$D_nt&yBlHg*gyTN9qg~$VdQl-el5}wmj?y*5dD9Ixdv8Na%@Bk$qJ2Zo}i#-maUFvbdBZ@pF zgeoJo+eDsHLb_H|Uq*Q4`$VpavyallQvRVFg(y#U_P=B~qQVXI#&1znK0@(qhSHaUejC{CS84Q@bracOZ@@n@;5qjvhHj46zC~9jJMGY0D#>1-_ zvZZltBb~rAc%~H3YxIgds#ZxL$D89JJrT$SEDN7JkO!ECU}nIyk{~kk0o4(v1u#9q z3Wn_I=5(EOS(5O9kfpqNUR~SYQ&g(SfHgx~|0!*=SKn5ip29V$&V<+XYXy{tFao4s7yXmz5=Y>%;3 zGR;6=*aF1?`;aSLDA$|swSXCRj|sZe!QZLMsq<3BlqV-;<<7S}xo%M-RD{+sqmiZx)( zJVmK+P?>>7VrQYcNY#|GWT2JTug;cPylM^QDd6|0>nq-rQyWBm4-&Y4Qp8e|qJVTS zC8zp+2mfD&|NZd45&kXwi|S@8%;&@5(OoTZ=dkVJ{ zZDs?dU{@*hc9AEkGqfgE+rT>h{3;{=YewGUVOtsU?BZ0%%PBQHQV|SBprpHGpEz;f zOYfyRnIdX0-OTKzT{ixES@otfbX+qvJ-Oe<`G-fw$NYh*KHnbx4_WP{l`QAKcg%m^ zuy5~_pBo?1 z1YHsiCv{u}rgBGy4{>87{&?2NG{*rWL2`MhpdW|!CZ~p{INzur%%AedHHSu~hWBz4 z2Zu@FxZq7&!y~@&k!c(+9IcT9 zV}X6c2mMnpw3Bhk-th_FR9w7gVq!ebj0L9RdBdZAZtPzF@XZq~ZW!J(JvKfH{X!-# zJ_z5bKjP@Y$5UX{{yk8AT)B6GJLsDl_8%T`m2oyS%+AO4+Z8yoIQ;za9R_nSuK&~< z5ZB$@D4A@e-{V=t_>|>|0ld{Pv|}qc3>x@Eg5R8<;{AAgw z%Ev38a4+T8M)GSH%A@(6Vcn|hx#|MVCzLuzb9}v!PZ{%1bUoY^&2}7LcO^SFrpx)n zNf`@Hw9K?zE9X11#|qvxnP=$Ok)sjay))8~Wqy2dQ+UJ9@a=bp$M5AAvz*7E#%z6E6No#G}Esz+cg-fd5K9-76BmVi6-=gauz=#VB=XdL`0V>bgZhyxOwP4z~*y zx>qT`P|#fh#Lq-@uUh^yrWkQ2-K&xRti;wEG%~Nt=w6-lbvc%Jy#m{Ky@~EMh+l8U zHePSXHeT@8S|!$0i3K*SRS7HTsckf0-ya^QR#aDmgMybL-B#aM-h@rcD#KkbpAgPm44 zP?T3XAVZ(RCiO^CK3PBQ6$fNqQ9us=3J)Dn7E)fxBW9!8OL=4-d8;U(@~Zgve8#lv zIQjrZ?R>$bz{b5YbU_j0?@}2x9!1JeNtI$#28`YY_NI1Dlvm|ZVyOW=x>achEFfQ@ zqLT2>%5(W&isXgqdKE+9(zL*L;6HnFjDEEQ>JE9mvyi)o8OD|u2be&l7eK0J2p^4Grc{^AcWzrRE1aq<4Kef#6g_{4o¨*>W&nu?`r!FfA1r?7%GbVh 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 delta 9411 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 at)CK^NRM(=G(XupTaofN{+0lj?fVaMY8jaT diff --git a/backend/app/engine/__pycache__/scheduler.cpython-313.pyc b/backend/app/engine/__pycache__/scheduler.cpython-313.pyc index 01db5f8138a5029d55fca6d3fddc758c659d4294..9f7731d81ee85bf6aecd17a529bff5ee058bbc9c 100644 GIT binary patch delta 1716 zcmah~Uu;uV7(b`?-uCwI+O2;^VYDk7qf0aEq1KHfnusd0d47rx|uH76hoZE`} zvN03b6qV&d#J|ZBK-7ecj35t0U{A!SPLOGXPtr2p7~{c2G=Arn(uD^-$@$%{=lj#| ze&6qW_r!CDH`x}fRuh8tsAjJJoO9e(iLZ>;b=wIhVuz*OO09mh2X`$SA9l5tHDYFo z5Vh|S0*DxisR>gDG4Cl~uOk*O8sKRKv3e185*wh4lmWVl9neD@fR!;_uzdXXS)VVG zhz!2_o-7@Tqx8ujRl5ez7@3!wz=LFz7N)kZ@% zwxI@H$%BZ**des*6FNWY)tr(-ru4cHZA#)R_WdiR}$V5aIn)naX(--D0p4sfDItWG!+!$h_Mh@c> zaaG z$k-ci@f#lK^g=(r<8HkC!i>9h*4{cQ+!1Y`n8(Z+cf;gMlN&GBWSm=n5x32A$kB%X zylZwYAdb0!m1WG5@vNT=Pj1h+Hr*1NeizsNX7ijfXX+4=J~T#AYJD$9A4)!ll7(+A9vlp zwZ+R#m*JLj{j^TJc;5VJ4*ouH&5 zG*#l=w}|d#N{vEgno=s>1xojU>WF&NxeD)9A3Lw?WOFTR0G_egl@*nEIu%VGN|eIU z2n$wN|dGXY^vgm waP46NY@r81%f2g0TwU#1fw!rho`xkx+>akthVZ(F+jJaWxnSXNz4oI20x>YZkpKVy delta 809 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 0 " - "AND id NOT IN (SELECT DISTINCT recommendation_id FROM recommendation_tracking WHERE status = 'closed') " + "AND COALESCE(lifecycle_status, 'candidate') NOT IN ('closed_win', 'closed_loss', 'invalidated', 'expired') " "AND date(created_at) <= date(:today) " "ORDER BY created_at DESC LIMIT 50" ), @@ -88,15 +89,44 @@ async def _update_tracking(): tracked = 0 for r in rows: - rec_id, ts_code, entry_price, target_price, stop_loss = r + rec_id, ts_code, entry_price, target_price, stop_loss, review_after_days, lifecycle_status, created_at = r current_price = price_map.get(ts_code) if current_price is None or entry_price is None or entry_price <= 0: continue - pct = round((current_price - entry_price) / entry_price * 100, 2) - hit_target = target_price and current_price >= target_price - hit_stop = stop_loss and current_price <= stop_loss - status = "closed" if (hit_target or hit_stop) else "active" + track_metrics = _calculate_tracking_metrics( + ts_code=ts_code, + entry_price=float(entry_price), + current_price=float(current_price), + created_at=created_at, + latest_trade_date=trade_date, + ) + pct = track_metrics["pct_from_entry"] + max_price = track_metrics["max_price"] + min_price = track_metrics["min_price"] + max_return_pct = track_metrics["max_return_pct"] + max_drawdown_pct = track_metrics["max_drawdown_pct"] + days_since = track_metrics["days_since_recommendation"] + + hit_target = bool(target_price and max_price >= target_price) + hit_stop = bool(stop_loss and min_price <= stop_loss) + review_days = int(review_after_days or 3) + expired = days_since >= review_days and not hit_target and not hit_stop + + status, new_lifecycle, close_reason = _derive_lifecycle_status( + hit_target=hit_target, + hit_stop=hit_stop, + expired=expired, + pct=pct, + previous_status=lifecycle_status or "candidate", + ) + review_note = _build_review_note( + pct=pct, + max_return_pct=max_return_pct, + max_drawdown_pct=max_drawdown_pct, + days_since=days_since, + close_reason=close_reason, + ) # 检查今天是否已经跟踪过 exists = await db.execute( @@ -115,11 +145,25 @@ async def _update_tracking(): track_date=trade_date, current_price=current_price, pct_from_entry=pct, + max_price=max_price, + min_price=min_price, + max_return_pct=max_return_pct, + max_drawdown_pct=max_drawdown_pct, + days_since_recommendation=days_since, hit_target=hit_target, hit_stop_loss=hit_stop, + close_reason=close_reason, + review_note=review_note, status=status, ) ) + await db.execute( + text( + "UPDATE recommendations SET lifecycle_status = :status " + "WHERE id = :rid" + ), + {"status": new_lifecycle, "rid": rec_id}, + ) tracked += 1 await db.commit() @@ -131,6 +175,93 @@ async def _update_tracking(): await log_error("recommender", f"更新推荐跟踪失败: {e}", detail=traceback.format_exc()) +def _calculate_tracking_metrics( + ts_code: str, + entry_price: float, + current_price: float, + created_at, + latest_trade_date: str, +) -> dict: + """计算推荐后的收益、最大收益和最大回撤。 + + 使用 Tushare 日线高低价回放推荐后的表现;失败时退化为当前价。 + """ + from app.data.tushare_client import tushare_client + + created_date = str(created_at)[:10] if created_at else "" + created_yyyymmdd = created_date.replace("-", "") if created_date else latest_trade_date + + max_price = current_price + min_price = current_price + days_since = 0 + + try: + df = tushare_client.get_stock_daily(ts_code, days=60) + if not df.empty: + df = df[df["trade_date"] >= created_yyyymmdd].sort_values("trade_date") + if not df.empty: + max_price = float(df["high"].max()) + min_price = float(df["low"].min()) + days_since = len(df["trade_date"].unique()) - 1 + except Exception as e: + logger.debug(f"计算跟踪指标失败 {ts_code}: {e}") + + pct = round((current_price - entry_price) / entry_price * 100, 2) + max_return_pct = round((max_price - entry_price) / entry_price * 100, 2) + max_drawdown_pct = round((min_price - entry_price) / entry_price * 100, 2) + + return { + "pct_from_entry": pct, + "max_price": round(max_price, 2), + "min_price": round(min_price, 2), + "max_return_pct": max_return_pct, + "max_drawdown_pct": max_drawdown_pct, + "days_since_recommendation": max(days_since, 0), + } + + +def _derive_lifecycle_status( + hit_target: bool, + hit_stop: bool, + expired: bool, + pct: float, + previous_status: str, +) -> tuple[str, str, str]: + if hit_target: + return "closed", "closed_win", "hit_target" + if hit_stop: + return "closed", "closed_loss", "hit_stop_loss" + if expired: + if pct > 0: + return "closed", "closed_win", "review_expired_profit" + if pct < -2: + return "closed", "closed_loss", "review_expired_loss" + return "closed", "expired", "review_expired_flat" + if previous_status == "actionable": + return "active", "tracking", "" + return "active", "tracking", "" + + +def _build_review_note( + pct: float, + max_return_pct: float, + max_drawdown_pct: float, + days_since: int, + close_reason: str, +) -> str: + if close_reason == "hit_target": + return f"{days_since}个交易日内命中目标,最大浮盈{max_return_pct}%" + if close_reason == "hit_stop_loss": + return f"{days_since}个交易日内触发止损,最大回撤{max_drawdown_pct}%" + if close_reason == "review_expired_profit": + return f"复盘窗口到期,当前收益{pct}%,最大浮盈{max_return_pct}%" + if close_reason == "review_expired_loss": + return f"复盘窗口到期,当前亏损{pct}%,最大回撤{max_drawdown_pct}%" + if close_reason == "review_expired_flat": + return f"复盘窗口到期,收益{pct}%,未形成有效进攻" + return f"跟踪中,当前收益{pct}%,最大浮盈{max_return_pct}%,最大回撤{max_drawdown_pct}%" + + async def get_performance_stats() -> dict: """获取推荐胜率统计""" try: @@ -200,13 +331,44 @@ async def get_performance_stats() -> dict: ) hit_stop_count = result.scalar() or 0 + # 生命周期分布 + result = await db.execute( + text( + "SELECT COALESCE(lifecycle_status, 'candidate') AS status, COUNT(*) AS cnt " + "FROM recommendations GROUP BY COALESCE(lifecycle_status, 'candidate')" + ) + ) + lifecycle_counts = { + row._mapping["status"]: row._mapping["cnt"] + for row in result.fetchall() + } + + # 最大浮盈/最大回撤统计 + 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" + ")" + ) + ) + avg_extremes = result.fetchone() + avg_max_return = round(float(avg_extremes[0]), 2) if avg_extremes and avg_extremes[0] is not None else 0 + avg_max_drawdown = round(float(avg_extremes[1]), 2) if avg_extremes and avg_extremes[1] is not None else 0 + # 最近跟踪的推荐详情 result = await db.execute( text( "SELECT r.ts_code, r.name, r.signal, r.entry_price, " " r.target_price, r.stop_loss, r.entry_signal_type, r.score, " + " r.action_plan, r.lifecycle_status, " " t.pct_from_entry, t.current_price, t.track_date, t.hit_target, t.hit_stop_loss, " - " r.created_at " + " t.max_return_pct, t.max_drawdown_pct, t.days_since_recommendation, " + " t.close_reason, t.review_note, r.created_at " "FROM recommendations r " "INNER JOIN recommendation_tracking t ON t.recommendation_id = r.id " "INNER JOIN (" @@ -224,12 +386,19 @@ async def get_performance_stats() -> dict: "name": r["name"], "signal": r["signal"], "entry_signal_type": r["entry_signal_type"], + "action_plan": r["action_plan"], + "lifecycle_status": r["lifecycle_status"], "score": r["score"], "entry_price": r["entry_price"], "target_price": r["target_price"], "stop_loss": r["stop_loss"], "current_price": r["current_price"], "pct_from_entry": r["pct_from_entry"], + "max_return_pct": r["max_return_pct"], + "max_drawdown_pct": r["max_drawdown_pct"], + "days_since_recommendation": r["days_since_recommendation"], + "close_reason": r["close_reason"], + "review_note": r["review_note"], "track_date": r["track_date"], "hit_target": bool(r["hit_target"]), "hit_stop_loss": bool(r["hit_stop_loss"]), @@ -244,8 +413,11 @@ async def get_performance_stats() -> dict: "winning": winning, "win_rate": win_rate, "avg_return": avg_return, + "avg_max_return": avg_max_return, + "avg_max_drawdown": avg_max_drawdown, "hit_target_count": hit_target_count, "hit_stop_count": hit_stop_count, + "lifecycle_counts": lifecycle_counts, "details": details, } except Exception as e: @@ -254,8 +426,9 @@ async def get_performance_stats() -> dict: await log_error("recommender", f"获取胜率统计失败: {e}", detail=traceback.format_exc()) return { "total_recommendations": 0, "tracked": 0, "winning": 0, - "win_rate": 0, "avg_return": 0, "hit_target_count": 0, - "hit_stop_count": 0, "details": [], + "win_rate": 0, "avg_return": 0, "avg_max_return": 0, + "avg_max_drawdown": 0, "hit_target_count": 0, + "hit_stop_count": 0, "lifecycle_counts": {}, "details": [], } @@ -279,15 +452,31 @@ async def get_recommendation_history(days: int = 7) -> list[dict]: # 查询所有历史推荐,按 ts_code 去重(每天取最新一条) stmt = text( - "SELECT * FROM recommendations " - "WHERE created_at >= :start " - "AND score >= 60 " - "AND id IN (" + "SELECT r.*, " + "latest_t.current_price AS latest_current_price, " + "latest_t.pct_from_entry AS latest_pct_from_entry, " + "latest_t.max_return_pct AS latest_max_return_pct, " + "latest_t.max_drawdown_pct AS latest_max_drawdown_pct, " + "latest_t.days_since_recommendation AS latest_days_since_recommendation, " + "latest_t.close_reason AS latest_close_reason, " + "latest_t.review_note AS latest_review_note, " + "latest_t.track_date AS latest_track_date " + "FROM recommendations r " + "LEFT JOIN (" + " SELECT t.* FROM recommendation_tracking t " + " INNER JOIN (" + " SELECT recommendation_id, MAX(id) AS max_id " + " FROM recommendation_tracking GROUP BY recommendation_id" + " ) lt ON t.id = lt.max_id" + ") latest_t ON latest_t.recommendation_id = r.id " + "WHERE r.created_at >= :start " + "AND r.score >= 60 " + "AND r.id IN (" " SELECT MAX(id) FROM recommendations " " WHERE created_at >= :start " " GROUP BY date(created_at), ts_code" ") " - "ORDER BY created_at DESC, score DESC" + "ORDER BY r.created_at DESC, r.score DESC" ) result = await db.execute(stmt, {"start": start}) rows = result.fetchall() @@ -324,11 +513,29 @@ async def get_recommendation_history(days: int = 7) -> list[dict]: "target_price": r["target_price"], "stop_loss": r["stop_loss"], "reasons": json.loads(r["reasons"]) if r["reasons"] else [], - "risk_note": "", + "risk_note": r.get("risk_note") or "", + "entry_timing": r.get("entry_timing") or "", + "action_plan": r.get("action_plan") or "观察", + "trigger_condition": r.get("trigger_condition") or "", + "invalidation_condition": r.get("invalidation_condition") or "", + "suggested_position_pct": r.get("suggested_position_pct") or 0, + "review_after_days": r.get("review_after_days") or 3, + "lifecycle_status": r.get("lifecycle_status") or "candidate", + "data_freshness": r.get("data_freshness") or "", "strategy": r.get("strategy") or "trend_breakout", "entry_signal_type": r.get("entry_signal_type") or "none", "llm_analysis": r.get("llm_analysis") or "", "llm_score": r.get("llm_score"), + "tracking": { + "current_price": r.get("latest_current_price"), + "pct_from_entry": r.get("latest_pct_from_entry"), + "max_return_pct": r.get("latest_max_return_pct"), + "max_drawdown_pct": r.get("latest_max_drawdown_pct"), + "days_since_recommendation": r.get("latest_days_since_recommendation"), + "close_reason": r.get("latest_close_reason") or "", + "review_note": r.get("latest_review_note") or "", + "track_date": r.get("latest_track_date") or "", + } if r.get("latest_track_date") else None, "scan_session": r["scan_session"] or "", "created_at": created_at_str, } @@ -370,7 +577,7 @@ async def _save_to_db(result: dict): """将推荐结果保存到数据库""" try: async with get_db() as db: - from sqlalchemy import text + from sqlalchemy import bindparam, text # 保存市场温度 mt = result.get("market_temp") if mt: @@ -428,9 +635,13 @@ async def _save_to_db(result: dict): if qualified_recs: # 批量删除当日同一 ts_code 的旧记录 codes = [rec.ts_code for rec in qualified_recs] + delete_stmt = text( + "DELETE FROM recommendations " + "WHERE date(created_at) = :today AND ts_code IN :codes" + ).bindparams(bindparam("codes", expanding=True)) await db.execute( - text("DELETE FROM recommendations WHERE date(created_at) = :today AND ts_code IN :codes"), - {"today": today_str, "codes": tuple(codes)}, + delete_stmt, + {"today": today_str, "codes": codes}, ) # 批量插入新记录 rec_values = [ @@ -452,9 +663,18 @@ async def _save_to_db(result: dict): "target_price": rec.target_price, "stop_loss": rec.stop_loss, "reasons": json.dumps(rec.reasons, ensure_ascii=False), + "risk_note": rec.risk_note, + "action_plan": rec.action_plan, + "trigger_condition": rec.trigger_condition, + "invalidation_condition": rec.invalidation_condition, + "suggested_position_pct": rec.suggested_position_pct, + "review_after_days": rec.review_after_days, + "lifecycle_status": rec.lifecycle_status, + "data_freshness": rec.data_freshness, "llm_analysis": rec.llm_analysis, "strategy": rec.strategy, "entry_signal_type": rec.entry_signal_type, + "entry_timing": rec.entry_timing, "llm_score": rec.llm_score, "scan_session": rec.scan_session, "created_at": now_dt, @@ -481,7 +701,10 @@ async def _load_today_from_db() -> dict: # 加载市场温度(按 trade_date 取最新交易日) result = await db.execute( - text("SELECT * FROM market_temperature ORDER BY trade_date DESC LIMIT 1") + text( + "SELECT * FROM market_temperature " + "ORDER BY REPLACE(trade_date, '-', '') DESC, id DESC LIMIT 1" + ) ) mt_row = result.fetchone() market_temp = None @@ -530,6 +753,15 @@ async def _load_today_from_db() -> dict: target_price=r["target_price"], stop_loss=r["stop_loss"], reasons=json.loads(r["reasons"]) if r["reasons"] else [], + risk_note=r.get("risk_note") or "", + entry_timing=r.get("entry_timing") or "", + action_plan=r.get("action_plan") or "观察", + trigger_condition=r.get("trigger_condition") or "", + invalidation_condition=r.get("invalidation_condition") or "", + suggested_position_pct=r.get("suggested_position_pct") or 0, + review_after_days=r.get("review_after_days") or 3, + lifecycle_status=r.get("lifecycle_status") or "candidate", + data_freshness=r.get("data_freshness") or "", llm_analysis=r.get("llm_analysis") or "", strategy=r.get("strategy") or "trend_breakout", entry_signal_type=r.get("entry_signal_type") or "none", @@ -542,6 +774,10 @@ async def _load_today_from_db() -> dict: "hot_sectors": [], "capital_filtered": [], "recommendations": recommendations, + "strategy_profile": { + "strategy_id": recommendations[0].strategy if recommendations else "trend_breakout", + "name": "当前推荐策略", + } if recommendations else None, } except Exception as e: logger.error(f"从数据库加载推荐失败: {e}") @@ -558,10 +794,14 @@ async def _load_sectors_from_db() -> list[SectorInfo]: result = await db.execute( text( "SELECT * FROM sector_heat " - "WHERE trade_date = (SELECT MAX(trade_date) FROM sector_heat) " + "WHERE REPLACE(trade_date, '-', '') = (" + " SELECT MAX(REPLACE(trade_date, '-', '')) FROM sector_heat" + ") " "AND id IN (" " SELECT MAX(id) FROM sector_heat " - " WHERE trade_date = (SELECT MAX(trade_date) FROM sector_heat) " + " WHERE REPLACE(trade_date, '-', '') = (" + " SELECT MAX(REPLACE(trade_date, '-', '')) FROM sector_heat" + " ) " " GROUP BY sector_code" ") " "ORDER BY heat_score DESC" diff --git a/backend/app/engine/scheduler.py b/backend/app/engine/scheduler.py index 43db1a8e..80d6b7ff 100644 --- a/backend/app/engine/scheduler.py +++ b/backend/app/engine/scheduler.py @@ -10,6 +10,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from app.engine.recommender import refresh_recommendations +from app.engine.watchlist import analyze_watchlist_for_all_users from app.api.websocket import broadcast_update logger = logging.getLogger(__name__) @@ -54,6 +55,18 @@ async def _generate_daily_review(): await log_error("scheduler", f"复盘报告生成异常: {e}", detail=traceback.format_exc()) +async def _run_watchlist_analysis(): + """收盘后自动分析所有用户自选股。""" + logger.info("=== 开始自选股定时分析 ===") + try: + count = await analyze_watchlist_for_all_users(mode="scheduled") + logger.info(f"自选股定时分析完成: {count} 条") + except Exception as e: + logger.error(f"自选股定时分析失败: {e}") + from app.db.error_logger import log_error + await log_error("scheduler", f"自选股定时分析失败: {e}", detail=traceback.format_exc()) + + def setup_scheduler(): """配置所有定时任务(交易日时间)""" @@ -110,6 +123,11 @@ def setup_scheduler(): id="daily_review", replace_existing=True ) + scheduler.add_job( + _run_watchlist_analysis, CronTrigger(hour=16, minute=20, day_of_week="mon-fri"), + id="watchlist_analysis", replace_existing=True + ) + logger.info("盘中调度器已配置完成") diff --git a/backend/app/engine/screener.py b/backend/app/engine/screener.py index 4574617e..6ee74363 100644 --- a/backend/app/engine/screener.py +++ b/backend/app/engine/screener.py @@ -30,6 +30,7 @@ from app.analysis.signals import generate_signals from app.analysis.intraday import intraday_market_temperature, intraday_filter_stocks, intraday_sector_scan from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation from app.config import settings, is_trading_hours, is_market_session +from app.llm.strategy_selector import select_strategy_profile logger = logging.getLogger(__name__) @@ -82,6 +83,12 @@ async def run_screening(trade_date: str = None) -> dict: if intraday: hot_sectors = await intraday_sector_scan(hot_sectors) + strategy_profile = await select_strategy_profile(market_temp, hot_sectors, intraday) + logger.info( + f"=== 今日策略: {strategy_profile.name} ({strategy_profile.strategy_id}) " + f"threshold={strategy_profile.buy_threshold} min_score={strategy_profile.min_score} ===" + ) + # ── Step 2: 板块内选股 ── logger.info("=== Step 2: 板块内选股 ===") if intraday: @@ -123,11 +130,11 @@ async def run_screening(trade_date: str = None) -> dict: # ── Step 3: 供需 + 价格行为 + 趋势评分 ── logger.info("=== Step 3: 深度分析 ===") recommendations = await _build_recommendations( - candidates, market_temp, hot_sectors, market_temp_score, intraday, + candidates, market_temp, hot_sectors, market_temp_score, intraday, strategy_profile, ) # 过滤低质量推荐(低于60分不推荐) - recommendations = [r for r in recommendations if r.score >= 60] + recommendations = [r for r in recommendations if r.score >= strategy_profile.min_score] logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===") for r in recommendations[:5]: @@ -140,6 +147,7 @@ async def run_screening(trade_date: str = None) -> dict: "hot_sectors": hot_sectors, "recommendations": recommendations, "scan_mode": scan_mode, + "strategy_profile": strategy_profile.model_dump(), } @@ -315,6 +323,7 @@ async def _build_recommendations( hot_sectors: list[SectorInfo], market_temp_score: float = 0, intraday: bool = False, + strategy_profile=None, ) -> list[Recommendation]: """Step 3: 对候选做供需 + 价格行为 + 趋势深度分析 @@ -345,6 +354,13 @@ async def _build_recommendations( llm_candidates = [] # 收集候选摘要供 LLM 分析 total = len(candidates) signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0} + score_weights = strategy_profile.score_weights if strategy_profile else { + "supply_demand": 0.50, + "price_action": 0.40, + "trend": 0.10, + } + signal_priority = strategy_profile.entry_signal_priority if strategy_profile else [] + buy_threshold = strategy_profile.buy_threshold if strategy_profile else 60 for idx, stock in enumerate(candidates): ts_code = stock.get("ts_code", "") @@ -377,6 +393,9 @@ async def _build_recommendations( if signal_type == EntrySignal.NONE: signal_counts["none"] += 1 continue + if signal_priority and signal_type.value not in signal_priority[:4]: + signal_counts["none"] += 1 + continue signal_counts[signal_type.value] += 1 # ── 三维度评分 ── @@ -400,9 +419,9 @@ async def _build_recommendations( # 综合评分(短线交易:供需最关键,趋势只做门槛) final_score = ( - supply_demand_score * 0.50 + - price_action_score * 0.40 + - trend_score * 0.10 + supply_demand_score * score_weights["supply_demand"] + + price_action_score * score_weights["price_action"] + + trend_score * score_weights["trend"] ) # ── 风险乘数:惩罚取最大而非叠加(避免过度惩罚),奖励可叠加 ── @@ -442,6 +461,15 @@ async def _build_recommendations( if entry_signal.get("signal_score", 0) >= 80: final_score *= 1.10 + if signal_priority: + priority_rank = signal_priority.index(signal_type.value) + if priority_rank == 0: + final_score *= 1.08 + elif priority_rank == 1: + final_score *= 1.04 + elif priority_rank >= 3: + final_score *= 0.94 + # 估值评分(辅助参考,不参与主评分) pe = stock.get("pe") pb = stock.get("pb") @@ -454,7 +482,7 @@ async def _build_recommendations( if (signal_type != EntrySignal.NONE and entry_signal.get("signal_score", 0) >= 50 and position_score >= 30 - and final_score >= 60): + and final_score >= buy_threshold): signal = "BUY" # 价格参考 — 结构化止损止盈(基于市场结构而非固定百分比) @@ -547,6 +575,7 @@ async def _build_recommendations( # 生成推荐理由 reasons = _generate_reasons(stock, entry_signal, tech_signal, df, intraday) + stock["entry_signal_type"] = signal_type.value risk_note = _generate_risk_note(market_temp, tech_signal, stock) # 量价模式 @@ -554,6 +583,17 @@ async def _build_recommendations( # 进场时机建议(盘中适用) entry_timing = _generate_entry_timing(signal_type.value, intraday) + trade_plan = _build_trade_plan( + signal_type=signal_type.value, + score=final_score, + market_temp=market_temp, + sector_stage=sector_stage, + entry_price=entry_price, + target_price=target_price, + stop_loss=stop_loss, + entry_timing=entry_timing, + data_date=last_date, + ) rec = Recommendation( ts_code=ts_code, @@ -575,9 +615,16 @@ async def _build_recommendations( reasons=reasons, risk_note=risk_note, level=level, - strategy="trend_breakout", + strategy=strategy_profile.strategy_id if strategy_profile else "trend_breakout", entry_signal_type=signal_type.value, entry_timing=entry_timing, + action_plan=trade_plan["action_plan"], + trigger_condition=trade_plan["trigger_condition"], + invalidation_condition=trade_plan["invalidation_condition"], + suggested_position_pct=trade_plan["suggested_position_pct"], + review_after_days=trade_plan["review_after_days"], + lifecycle_status=trade_plan["lifecycle_status"], + data_freshness=trade_plan["data_freshness"], ) recommendations.append(rec) @@ -963,6 +1010,86 @@ def _generate_entry_timing(signal_type: str, intraday: bool) -> str: return timing_map.get(signal_type, "盘中观察量价配合,确认信号后进场") +def _build_trade_plan( + signal_type: str, + score: float, + market_temp: MarketTemperature, + sector_stage: str, + entry_price: float | None, + target_price: float | None, + stop_loss: float | None, + entry_timing: str, + data_date: str, +) -> dict: + """把推荐转成可执行计划。 + + 这里不替代用户决策,只把系统推荐拆成触发、失效、仓位和复盘窗口。 + """ + signal_label = { + "breakout": "放量突破", + "breakout_confirm": "突破确认", + "pullback": "回踩支撑", + "launch": "缩量整理后启动", + "reversal": "放量反转", + }.get(signal_type, "技术信号") + + if market_temp.temperature < 35 or sector_stage in ("end",): + action_plan = "观察" + lifecycle_status = "candidate" + elif score >= 78 and market_temp.temperature >= 55 and sector_stage in ("early", "mid"): + action_plan = "可操作" + lifecycle_status = "actionable" + elif score >= 65: + action_plan = "重点关注" + lifecycle_status = "candidate" + else: + action_plan = "观察" + lifecycle_status = "candidate" + + if action_plan == "可操作": + base_position = 20 + elif action_plan == "重点关注": + base_position = 10 + else: + base_position = 0 + + if market_temp.temperature >= 70: + base_position += 5 + elif market_temp.temperature < 50: + base_position -= 5 + if sector_stage == "late": + base_position -= 5 + suggested_position_pct = max(0, min(base_position, 30)) + + price_part = f"参考价 {entry_price}" if entry_price else "参考当前价" + timing_part = entry_timing or "等待量价确认" + trigger_condition = f"{signal_label}成立且不跌破关键价位,{price_part}附近分批关注;{timing_part}" + + invalid_parts = [] + if stop_loss: + invalid_parts.append(f"跌破止损 {stop_loss}") + if entry_price: + invalid_parts.append(f"收盘跌回参考价 {round(entry_price * 0.98, 2)} 下方") + if target_price: + invalid_parts.append(f"冲高接近目标 {target_price} 后量能衰减") + if market_temp.temperature < 45: + invalid_parts.append("市场温度继续走弱") + invalidation_condition = ";".join(invalid_parts) or "信号次日未延续或板块热度退潮" + + review_after_days = 1 if signal_type in ("breakout", "reversal") else 3 + data_freshness = f"K线数据日期 {data_date};盘中价格优先使用腾讯实时行情" + + return { + "action_plan": action_plan, + "trigger_condition": trigger_condition, + "invalidation_condition": invalidation_condition, + "suggested_position_pct": suggested_position_pct, + "review_after_days": review_after_days, + "lifecycle_status": lifecycle_status, + "data_freshness": data_freshness, + } + + def _score_to_level(score: float) -> str: if score >= 80: return "强烈推荐" diff --git a/backend/app/engine/watchlist.py b/backend/app/engine/watchlist.py new file mode 100644 index 00000000..10bb88e3 --- /dev/null +++ b/backend/app/engine/watchlist.py @@ -0,0 +1,239 @@ +"""用户自选股分析服务""" + +from __future__ import annotations + +import json +import logging +import re + +from sqlalchemy import text + +from app.analysis.signals import generate_signals +from app.data import tencent_client +from app.db.database import get_db +from app.db import tables +from app.llm.client import chat_completion + +logger = logging.getLogger(__name__) + + +async def analyze_watchlist_for_all_users(mode: str = "scheduled") -> int: + """批量分析所有启用中的用户自选股。""" + async with get_db() as db: + rows = (await db.execute( + text( + "SELECT w.id, w.user_id, w.ts_code, w.name, w.note, w.watch_group, w.cost_price " + "FROM user_watchlists w " + "WHERE COALESCE(w.is_active, 1) = 1 " + "ORDER BY w.user_id, w.id" + ) + )).fetchall() + + count = 0 + for row in rows: + item = row._mapping + await analyze_watchlist_item( + watchlist_id=item["id"], + user_id=item["user_id"], + ts_code=item["ts_code"], + name=item["name"], + note=item.get("note") or "", + watch_group=item.get("watch_group") or "observe", + cost_price=item.get("cost_price"), + mode=mode, + ) + count += 1 + return count + + +async def analyze_watchlist_item( + watchlist_id: int, + user_id: int, + ts_code: str, + name: str, + note: str = "", + watch_group: str = "observe", + cost_price: float | None = None, + mode: str = "manual", +) -> dict: + """分析单只自选股并保存结果。""" + recommendation = await _load_latest_recommendation(ts_code) + latest_tracking = await _load_latest_tracking(recommendation["id"]) if recommendation else None + + try: + quote = await tencent_client.get_realtime_quote(ts_code) + except Exception: + logger.exception("获取自选股实时行情失败: %s", ts_code) + quote = None + + try: + signals = generate_signals(ts_code) + except Exception: + logger.exception("生成自选股信号失败: %s", ts_code) + signals = None + + summary = _build_summary(ts_code, name, recommendation, latest_tracking, quote, signals, note, watch_group, cost_price) + + llm_result = await _generate_watchlist_advice(summary) + structured = _extract_structured_result(llm_result, recommendation, latest_tracking) + + async with get_db() as db: + await db.execute( + tables.watchlist_analyses_table.insert().values( + user_id=user_id, + watchlist_id=watchlist_id, + ts_code=ts_code, + name=name, + conclusion=structured["conclusion"], + advice=structured["advice"], + trigger_condition=structured["trigger_condition"], + risk_note=structured["risk_note"], + summary=structured["summary"], + full_analysis=structured["full_analysis"], + score_reference=structured["score_reference"], + analysis_mode=mode, + ) + ) + await db.commit() + + return structured + + +async def _load_latest_recommendation(ts_code: str) -> dict | None: + async with get_db() as db: + row = (await db.execute( + text( + "SELECT * FROM recommendations " + "WHERE ts_code = :code " + "ORDER BY created_at DESC, id DESC LIMIT 1" + ), + {"code": ts_code}, + )).fetchone() + return dict(row._mapping) if row else None + + +async def _load_latest_tracking(recommendation_id: int) -> dict | None: + async with get_db() as db: + row = (await db.execute( + text( + "SELECT * FROM recommendation_tracking " + "WHERE recommendation_id = :rid " + "ORDER BY track_date DESC, id DESC LIMIT 1" + ), + {"rid": recommendation_id}, + )).fetchone() + return dict(row._mapping) if row else None + + +def _build_summary( + ts_code: str, + name: str, + recommendation: dict | None, + latest_tracking: dict | None, + quote, + signals, + note: str, + watch_group: str, + cost_price: float | None, +) -> str: + quote_str = "" + if quote: + quote_str = f"当前价 {quote.price},涨跌幅 {quote.pct_chg}%,换手率 {quote.turnover_rate}%,量比 {quote.volume_ratio}。" + + recommendation_str = "暂无推荐归档。" + if recommendation: + recommendation_str = ( + f"推荐归档:结论 {recommendation.get('action_plan') or '观察'}," + f"触发条件 {recommendation.get('trigger_condition') or '暂无'}," + f"失效条件 {recommendation.get('invalidation_condition') or '暂无'}," + f"风险提示 {recommendation.get('risk_note') or '暂无'}。" + ) + + tracking_str = "" + if latest_tracking: + tracking_str = ( + f"最近跟踪:收益 {latest_tracking.get('pct_from_entry') or 0}%," + f"最大浮盈 {latest_tracking.get('max_return_pct') or 0}%," + f"最大回撤 {latest_tracking.get('max_drawdown_pct') or 0}%," + f"备注 {latest_tracking.get('review_note') or '暂无'}。" + ) + + signal_str = "技术快照暂无。" + if signals: + signal_str = ( + f"技术快照:趋势强度 {signals.trend_score},辅助信号 {signals.signal_count}/7," + f"位置安全 {signals.position_score},近5日涨幅 {signals.rally_pct_5d}% ,近10日涨幅 {signals.rally_pct_10d}%。" + ) + + group_str = f"用户分组:{watch_group}。" + cost_str = f"持仓成本 {cost_price}。" if cost_price and cost_price > 0 else "暂无持仓成本。" + note_str = f"用户备注:{note}" if note else "用户未填写备注。" + return f"{ts_code} {name}。{group_str} {cost_str} {quote_str} {recommendation_str} {tracking_str} {signal_str} {note_str}" + + +async def _generate_watchlist_advice(summary: str) -> str: + message = await chat_completion([ + { + "role": "system", + "content": ( + "你是A股投研作战台的用户自选股助手。" + "你需要针对单只用户自选股给出简洁、可执行的建议。" + "输出必须是 JSON 字符串,包含字段 conclusion、advice、trigger_condition、risk_note、summary。" + "conclusion 只能是 可操作 / 重点关注 / 观察 / 回避。" + "summary 必须是一句中文短句。advice 需要明确用户下一步该看什么、等什么或做什么。" + ), + }, + { + "role": "user", + "content": f"请基于以下信息输出 JSON:{summary}", + }, + ]) + + if not message or not getattr(message, "content", None): + return "" + return message.content + + +def _extract_structured_result(content: str, recommendation: dict | None, latest_tracking: dict | None) -> dict: + default = { + "conclusion": recommendation.get("action_plan") if recommendation else "观察", + "advice": recommendation.get("trigger_condition") if recommendation else "继续观察量价配合、板块强弱和回踩承接。", + "trigger_condition": recommendation.get("trigger_condition") if recommendation else "", + "risk_note": recommendation.get("risk_note") if recommendation else (latest_tracking.get("review_note") if latest_tracking else ""), + "summary": latest_tracking.get("review_note") if latest_tracking else "当前信息不足以升级为明确操作,先保留观察。", + "full_analysis": content or "", + "score_reference": float(recommendation.get("score") or 0) if recommendation else 0, + } + + if not content: + return default + + try: + parsed = json.loads(_extract_json_string(content)) + return { + "conclusion": parsed.get("conclusion") or default["conclusion"], + "advice": parsed.get("advice") or default["advice"], + "trigger_condition": parsed.get("trigger_condition") or default["trigger_condition"], + "risk_note": parsed.get("risk_note") or default["risk_note"], + "summary": parsed.get("summary") or default["summary"], + "full_analysis": content, + "score_reference": default["score_reference"], + } + except Exception: + logger.warning("自选股分析 JSON 解析失败,回退默认结构") + default["full_analysis"] = content + return default + + +def _extract_json_string(content: str) -> str: + cleaned = content.strip() + if cleaned.startswith("```"): + fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", cleaned, re.DOTALL) + if fenced: + return fenced.group(1) + + start = cleaned.find("{") + end = cleaned.rfind("}") + if start != -1 and end != -1 and end > start: + return cleaned[start : end + 1] + return cleaned diff --git a/backend/app/llm/__pycache__/chat_agent.cpython-313.pyc b/backend/app/llm/__pycache__/chat_agent.cpython-313.pyc index 22b2643d064c16aeba20b9c9edf182c5dda7e73f..f3a2494afff7bbac8fb141101d867a4f83a551fd 100644 GIT binary patch delta 2619 zcmZ`*Yiv{39Y6Qs>+47Sh#kLUJC2iJk~kES28BFG2!l8eiZ9-WIh`?%^CH;n+F`9) zYSa%c&<~`#r3n=iL{--?X@y9W7RD+@*Qt;XmA$}h8P*{!3+XqTWL-Dy!~Q3UG2OZ& z-E)5DfBvuY`=4{~owt8jS+iDBB1O=GO{-(CD-rrDo)ns73#?_|t??&3N?vJ49Ko@@ zDvz31+tDEDbry7sqXP|UnuyIgujM3s87JjroSZA+6r7S%acZvA&GI@<&uIqrg9Z_( za@s)yN7jo0l?~DuH6Ptp~-kS5RXPDvSqPw zJP;ZU#sibFFdqm-Bk}MnaTk$gVIe*i8Hs(3olP2`TfKhkZx`NQzIA?O`lIDfFRz@P zd2&lw{p2@79r*)GQDOD%UwnP$-0GXNn>s7=SB0OD?PYAXbOd6>;(Rb39(grzEE?p8 zgd1RL#`2BuKMlr1qZ4B>P>Tdl#zv!Y;qPRZULKm{`EX=YDa-KT_#__*k-}a|n|K4# zt3-Bm5&5@>L#PRo#Vtbm$ZeYt4M9bq03=7b$!7W#x+t=v5!z2}Eh8UMARY=O^3w%5 z$GFJ`B0~8nFHrp7%_n?H$+laOzff=F-#8Okui#IE4iE<)LN>II7IKtcP1upYq~OU% z`4xhJE^ib0M9*?9+sIY+ELX4qn`gOlbip^?gHPmD315oMRj_V{SclF*u9|}X;>gKC zQ!8Ait2)7=C@&xl`XLh|8&HhYpo8s@FXLmpy25I4Qlr; zYCt4M5y}6dTmSv^`6R+oab+!mOV$4sLNX|t6~S&wzO##lXGu`PCt|Zm>XSn8z7$s_ zU@WW^$LDSPcOb-~1LOb^BkfSm2s4Z-pa2DJucLs9qZkDle0+AGh}Z_14Kxpj3V=M&CVw;QV{fX2FFJVV@i@g4sZpiM<+&hflc3EX_wJ!aSDpm|0kf z2ji2mNu1`&#a}&{dh7W&&GN$e<%M@v-hW#-FPSJcVXF%0ToiA?*eI~lzBUZAa{OKk zgn|2|wa1|Bw!c&YG~beE$FdtibmPlh9#wmd$b8=-j2 z8eb_ql=iVQ9&RY&b;5SpVYLjPz1Xtl!n~|j+5u8*5j!QU$Qn+?vSI zZ6fEg(Rw+NHa48?$tVpoo$tJnvDG9SkEC?IC6#a4xNWZdx8o^ePg>J+w)-oMK4aUK zYzn4y$CgybGAiBrD1ju_nV|=4WiCV@tzo)jx+AM9%Q2|Ll$P65a(l*5nK9e0v|egW zo9pLx=CsIGc_sQmbS&R zFWjiVZ{GV4L`u4esj7@pd*Sqj)8|e@k#6{Y{qp?FDc!yYs(nw7LuEX~KcAX_uW#2K z$Y4q9Yf}k*?HVG~){MDa=+04q{*_lCQ#X;fV1G&TUSMjgxZmDq%Qve0@7T}bG`-nL zc-n|N^iEGby7MB@)6Cpy)&l-aCI|Uv`c4uizc+S@dRi2pTMaPzym@<12mObNu5y@s z(N1_QY~qWKP8MeO#I(mw-jfj?D|1gyVXRbPd1V>yTSU0;(0PpF#WD);qJhBJNMmdz zK!4FrU|fNNFV>fMTByYq3b2b9aGA2=Kwu~u3IupJWS$jIL`O#8ha$@e4-_X8JpN8* zrEt~U@Y69oHp1h3l$F50Kp!ZE`79d)5>;Aa z`lG}&vwKP6Ji|OL)tpi0SY&R>Sa)XhwHe2YnaZ||-i4=HM_w&al4oRj!vH}zuhLhK n%t~_z;|1sMv^Q%vaq@5=d2}c_Jf1u{o;rN|n+*lN9}@WwQ5b-E delta 2195 zcmZ`)TWlLu8a{LN*fVyV@nsxOVo#hnwqt5%o3u@vrm0JBaY`3FYhD~MmgAT}YFA^Y zLI_9%5(--Fre(H*B1#h>(ewe@Y99#Ft~O}9qRk5#++7b=SykH7g@={wCd)4H!kISH z>;gT~ob&zXznuSe&b-@kSqQ9|%_acHr(a*6xMl|69~z+#!v_EiY9!z+~(>7qwF^19xd5KqhuFNA}unaS+s~vqBTNF4zYREIm!`GNMuLxQBFkr zP>t%tMVlKugG;VF8nREl3%99N|tA>{BX00k_G$sgcck`O?O z)}@kYiqWxX+KCPW5q}y3-~@6SJ4!I1fG6?bhQJss2h1}qVi9x)egd3^mIRQ85($gk zL-=_#(X89rTtx=*S@}5O$27S|Njv*EQ9`4jgxbL2JIGhGq5u7>xPB@ z3fLbvkTGe5MMmXn_|{u6P?PnjHE2lPVHGF~G~3#V<(NI?mA1c${CbbdcU-b4>`;Xny5;Dcf8r0_isL}F>A)=h_a>Xlcdq{8 z359*K!aVso!`4Y)wa-n@P1l%ah>gxF)v8dfs@1m?zIqej+osR?&-m5WuA74IJiS0yg&sxdQT>7QsfAS4zf1A&Y7khvbKPKQ zW*b(h*U&693>J@R9axNX-3M&k8`0lIXZO`n%^=BsxCm$3TK zUd0mo8x?E7C*1i^{b>Xk%`mO@)gL z*5#7p!?w$9uyB86hVC3gfb}5q?HxBv*MHZ2!0xKr0*WoL1`xAb26eOIj}^zJCUbkFI3%?< zy!4_>H+N>f0Ff_%_omTWHM&1Dy5H=t7&~Xko2+f7sZIfJugZm0-*%M?sh+K>-PbT1 qn$Q`#ZjU3#USuAh|J8!24(<|*FD;EN?_YXGi4On!-i|a3&-npac<`wJ diff --git a/backend/app/llm/__pycache__/enhancer.cpython-313.pyc b/backend/app/llm/__pycache__/enhancer.cpython-313.pyc index 6d46583bff7d00352b8ecf3881894683db450a94..1499f2a421b572c4c61fcb75c280c83cf8890c83 100644 GIT binary patch literal 807 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 delta 361 zcmZ2va!!x$GcPX}0}#yOxu5AMzL8Iul~H%HACFx0v#zNxwzj-nxbNlkbqbE23QxOM zKI`m$+|cx7?}DfO>!0ji`m$lxi~a3Sw=aF#y8YSwr7zYmdb)1jv+3KO?r3^CXV&3ctlT+FICm-MlQwBSOOIP8^j@}o`8lUXl4|EC8*+6#!4S<+BIhj+14a)50 zOrPw}6~t&fnUhE_woY<%ht`k}?CMaBAwiADK&`o;OBMail0$@wX% k`iaFQ`N`S3iRr0%CHj*WN&0YziLqQ@5c*&;`J1E;044^!dH?_b diff --git a/backend/app/llm/__pycache__/tool_executor.cpython-313.pyc b/backend/app/llm/__pycache__/tool_executor.cpython-313.pyc index 38a1e683185bd87aca73c8aeba2c90edf10dd712..f5a3f1d42e3b1e1d4d05959ea8e66508d44a32f5 100644 GIT binary patch delta 6918 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 L8o7$(fkpxVriu1P delta 414 zcmX>n{#``oG>b%}i`b%<6Xfp~b01#rlbfMfrL9 z#rdU0$*J+l`6;RTiNz)P$=SMz>8W`o`jb;Rl_lkYVaW)@#jk AsyncGenerator[dict, None]: +async def chat_stream(messages: list[dict], current_user: dict | None = None) -> AsyncGenerator[dict, None]: """流式对话,支持 tool use 循环 Yields: @@ -40,67 +42,71 @@ async def chat_stream(messages: list[dict]) -> AsyncGenerator[dict, None]: yield {"type": "content", "content": "LLM 未配置,请在 .env 中设置 ASTOCK_DEEPSEEK_API_KEY"} return + set_chat_user_context(current_user) + # 构建完整消息列表 full_messages = [{"role": "system", "content": CHAT_SYSTEM_PROMPT}] full_messages.extend(messages) - # Tool use 循环(非流式,直到没有 tool_calls) - for round_num in range(MAX_TOOL_ROUNDS): - if round_num == 0: - yield {"type": "status", "content": "思考中..."} + try: + # Tool use 循环(非流式,直到没有 tool_calls) + for round_num in range(MAX_TOOL_ROUNDS): + if round_num == 0: + yield {"type": "status", "content": "整理今日作战上下文..."} - resp = await chat_completion(full_messages, tools=CHAT_TOOLS) - if not resp: - yield {"type": "content", "content": "AI 服务暂时不可用,请稍后重试"} - return + resp = await chat_completion(full_messages, tools=CHAT_TOOLS) + if not resp: + yield {"type": "content", "content": "AI 服务暂时不可用,请稍后重试"} + return - # 检查是否有 tool_calls - if not resp.tool_calls: - break - - # 将 assistant 消息(含 tool_calls)加入历史 - full_messages.append({ - "role": "assistant", - "content": resp.content or "", - "tool_calls": [ - { - "id": tc.id, - "type": "function", - "function": { - "name": tc.function.name, - "arguments": tc.function.arguments, - }, - } - for tc in resp.tool_calls - ], - }) - - # 执行每个工具调用 - for tc in resp.tool_calls: - try: - args = json.loads(tc.function.arguments) - except json.JSONDecodeError: - args = {} - - tool_label = TOOL_LABELS.get(tc.function.name, tc.function.name) - yield {"type": "status", "content": f"正在{tool_label}..."} - - logger.info(f"Chat Agent 调用工具: {tc.function.name}({args})") - result = await execute_tool(tc.function.name, args) + # 检查是否有 tool_calls + if not resp.tool_calls: + break + # 将 assistant 消息(含 tool_calls)加入历史 full_messages.append({ - "role": "tool", - "tool_call_id": tc.id, - "content": result, + "role": "assistant", + "content": resp.content or "", + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in resp.tool_calls + ], }) - yield {"type": "status", "content": "分析数据中..."} - else: - # 超过最大轮次,用最后的消息生成回复 - pass + # 执行每个工具调用 + for tc in resp.tool_calls: + try: + args = json.loads(tc.function.arguments) + except json.JSONDecodeError: + args = {} - # 最终回复:流式输出 - yield {"type": "status", "content": ""} # 清除状态 - async for delta in stream_chat_completion(full_messages): - if delta.content: - yield {"type": "content", "content": delta.content} + tool_label = TOOL_LABELS.get(tc.function.name, tc.function.name) + yield {"type": "status", "content": f"正在{tool_label}..."} + + logger.info(f"Chat Agent 调用工具: {tc.function.name}({args})") + result = await execute_tool(tc.function.name, args) + + full_messages.append({ + "role": "tool", + "tool_call_id": tc.id, + "content": result, + }) + + yield {"type": "status", "content": "整理作战结论中..."} + else: + pass + + # 最终回复:流式输出 + yield {"type": "status", "content": ""} # 清除状态 + async for delta in stream_chat_completion(full_messages): + if delta.content: + yield {"type": "content", "content": delta.content} + finally: + set_chat_user_context(None) diff --git a/backend/app/llm/daily_review.py b/backend/app/llm/daily_review.py index c2cbd542..b927b58e 100644 --- a/backend/app/llm/daily_review.py +++ b/backend/app/llm/daily_review.py @@ -1,7 +1,6 @@ """每日复盘报告生成""" import logging -from datetime import datetime from app.config import settings @@ -10,13 +9,9 @@ logger = logging.getLogger(__name__) async def generate_review() -> dict: """生成每日复盘报告""" - if not settings.deepseek_api_key: - return {"status": "error", "message": "未配置 DeepSeek API Key"} - from app.data.tushare_client import tushare_client from app.data import tencent_client from app.engine.recommender import get_latest_recommendations, get_latest_sectors - from app.llm.client import get_client trade_date = tushare_client.get_latest_trade_date() @@ -83,19 +78,43 @@ async def generate_review() -> dict: ## 明日关注 (关注方向和操作建议)""" - try: - client = get_client() - response = await client.chat.completions.create( - model=settings.deepseek_model, - messages=[ - {"role": "system", "content": "你是一位专业的A股市场分析师,擅长市场复盘和策略分析。回复使用Markdown格式,简洁专业。"}, - {"role": "user", "content": user_msg}, - ], - max_tokens=1500, - temperature=0.5, - ) - content = response.choices[0].message.content.strip() + if settings.deepseek_api_key: + try: + from app.llm.client import get_client + client = get_client() + response = await client.chat.completions.create( + model=settings.deepseek_model, + messages=[ + {"role": "system", "content": "你是一位专业的A股市场分析师,擅长市场复盘和策略分析。回复使用Markdown格式,简洁专业。"}, + {"role": "user", "content": user_msg}, + ], + max_tokens=1500, + temperature=0.5, + ) + content = response.choices[0].message.content.strip() + generated_by = "llm" + except Exception as e: + logger.error(f"生成复盘报告失败,使用规则兜底: {e}") + content = _build_fallback_review( + trade_date=trade_date, + market_summary=market_summary, + index_summary=index_summary, + sector_summary=sector_summary, + recommendations=recs, + ) + generated_by = "rules" + else: + content = _build_fallback_review( + trade_date=trade_date, + market_summary=market_summary, + index_summary=index_summary, + sector_summary=sector_summary, + recommendations=recs, + ) + generated_by = "rules" + + try: # 保存到数据库 from sqlalchemy import text from app.db.database import get_db @@ -109,9 +128,40 @@ async def generate_review() -> dict: ) await db.commit() - logger.info(f"已生成 {trade_date} 复盘报告") - return {"status": "ok", "trade_date": trade_date, "content": content} + logger.info(f"已生成 {trade_date} 复盘报告 ({generated_by})") + return {"status": "ok", "trade_date": trade_date, "content": content, "generated_by": generated_by} except Exception as e: - logger.error(f"生成复盘报告失败: {e}") + logger.error(f"保存复盘报告失败: {e}") return {"status": "error", "message": str(e)} + + +def _build_fallback_review( + trade_date: str, + market_summary: str, + index_summary: str, + sector_summary: str, + recommendations: list, +) -> str: + """LLM 不可用时生成结构化规则复盘,避免页面空白。""" + actionable = [r for r in recommendations if getattr(r, "action_plan", "") == "可操作"] + watch = [r for r in recommendations if getattr(r, "action_plan", "") == "重点关注"] + top_recs = recommendations[:5] + rec_lines = "\n".join( + f"- {r.name}({r.ts_code}):{getattr(r, 'action_plan', '观察')}," + f"{getattr(r, 'entry_signal_type', 'none')} 信号,评分 {getattr(r, 'score', 0)}。" + for r in top_recs + ) or "- 暂无推荐标的。" + + return f"""## 市场概况 +{trade_date} 市场温度处于中性偏谨慎区间。{market_summary or "暂无市场温度数据。"} {index_summary or ""} + +## 板块热点 +{sector_summary or "暂无板块热度数据。"} 当前板块证据主要用于确认推荐方向是否有资金和赚钱效应支撑。 + +## 交易机会 +今日推荐池共 {len(recommendations)} 只,其中可操作 {len(actionable)} 只、重点关注 {len(watch)} 只。当前更适合按触发条件等待确认,不宜把观察标的直接当作买入标的。 +{rec_lines} + +## 明日关注 +优先跟踪重点关注标的能否满足触发条件,同时观察主线板块是否延续。若市场温度回落或板块资金退潮,应降低仓位并把未确认标的转回观察池。""" diff --git a/backend/app/llm/prompts.py b/backend/app/llm/prompts.py index 90f860a5..38c16cea 100644 --- a/backend/app/llm/prompts.py +++ b/backend/app/llm/prompts.py @@ -34,25 +34,29 @@ ENHANCE_USER_TEMPLATE = """\ 请对该股票进行 2-3 句话的深度分析:""" CHAT_SYSTEM_PROMPT = """\ -你是一位专业的 A 股投资顾问 AI 助手。你可以通过工具查询实时市场数据来回答用户问题。 +你是 A 股投研作战台里的 AI 作战助理,不是泛化闲聊机器人。你的核心任务是解释系统已经生成的结果,并帮助用户把市场、板块、推荐和自选股串成可执行判断。 你的能力: -1. 查询市场温度、热门板块、推荐股票列表 -2. 查询个股K线、资金流向数据 -3. 搜索股票代码 -4. 基于数据给出专业的市场分析和投资建议 +1. 查询今日作战结论,包括市场状态、今日打法、建议仓位、重点板块和规避规则 +2. 查询市场温度、热门板块、推荐股票列表 +3. 查询当前用户的自选股池与最新建议 +4. 查询个股K线、技术面、资金流向数据 +5. 搜索股票代码,并把结果放回当前交易语境中分析 重要提醒: - 回答用户关于"今天市场怎么样"之类的问题时,必须调用 get_realtime_indices 获取实时指数数据 -- 盘中时段(9:30-15:00)必须使用实时数据,盘后时段使用当日收盘数据 -- 不要使用过时的数据,必须先调用工具获取最新数据再回答 +- 回答用户关于"今天该怎么做"、"当前推荐怎么看"、"自选股该怎么处理"这类问题时,优先调用 get_strategy_board、get_latest_recommendations、get_user_watchlist_snapshot +- 盘中时段(9:30-15:00)必须使用实时数据,盘后时段使用当日收盘或最近一次系统生成的数据 +- 不要脱离系统上下文泛泛而谈,必须先调用工具获取最新结果再回答 回答要求: 1. 使用工具获取最新数据后再回答,不要凭空编造数据 -2. 分析要结合 A 股市场特点(资金驱动、板块轮动、情绪周期) -3. 给出具体建议时要附带风险提示 -4. 语言简洁、专业、有条理 -5. 回复使用 markdown 格式,适当用列表和加粗提升可读性 +2. 优先把结论组织成“当前判断 / 依据 / 下一步观察点 / 风险提示” +3. 分析要结合 A 股市场特点(资金驱动、板块轮动、情绪周期) +4. 如果用户问题过于宽泛,主动收敛到系统里的现成模块,不要输出空泛宏论 +5. 给出具体建议时要附带风险提示,并明确这是观察建议、执行条件还是规避建议 +6. 语言简洁、专业、有条理 +7. 回复使用 markdown 格式,适当用列表和加粗提升可读性 免责声明:你的分析仅供参考,不构成投资建议。投资有风险,入市需谨慎。 """ diff --git a/backend/app/llm/strategy_board.py b/backend/app/llm/strategy_board.py new file mode 100644 index 00000000..3f9eb0bb --- /dev/null +++ b/backend/app/llm/strategy_board.py @@ -0,0 +1,268 @@ +"""市场作战面板 + +把市场温度、板块、推荐和历史跟踪结果汇总成每天可执行的策略视图。 +规则层保证稳定输出,LLM 层负责补充解释和迭代建议。 +""" + +import logging + +from app.config import settings +from app.data.models import ( + MarketTemperature, + Recommendation, + SectorInfo, + StrategyBoard, + StrategyFocus, + StrategySectorFocus, +) + +logger = logging.getLogger(__name__) + + +async def build_strategy_board(include_llm: bool = False) -> dict: + """生成今日市场作战面板。""" + from app.engine.recommender import ( + get_latest_recommendations, + get_latest_sectors, + get_performance_stats, + ) + + latest = await get_latest_recommendations() + market_temp = latest.get("market_temp") + recommendations = latest.get("recommendations", []) + sectors = await get_latest_sectors() + performance = await get_performance_stats() + from app.llm.strategy_iteration import build_strategy_iteration_report + iteration_report = await build_strategy_iteration_report(limit=50, include_llm=include_llm) + + board = _build_rule_board(market_temp, sectors, recommendations, performance) + board.iteration_report = iteration_report + if iteration_report.get("adjustment_suggestions"): + board.iteration_notes = [ + s.get("reason", "") + for s in iteration_report["adjustment_suggestions"][:3] + if s.get("reason") + ] or board.iteration_notes + + if include_llm and settings.deepseek_api_key: + board.ai_review = await _generate_ai_review(board, recommendations, performance) + if board.ai_review: + board.generated_by = "rules+llm" + + return board.model_dump() + + +def _build_rule_board( + market_temp: MarketTemperature | None, + sectors: list[SectorInfo], + recommendations: list[Recommendation], + performance: dict, +) -> StrategyBoard: + temp = market_temp.temperature if market_temp else 0 + trade_date = market_temp.trade_date if market_temp else "" + market_regime, risk_level, action_bias, position_suggestion = _classify_market(temp, market_temp) + + actionable = [r for r in recommendations if r.action_plan == "可操作"] + watch = [r for r in recommendations if r.action_plan == "重点关注"] + avg_score = ( + round(sum(r.score for r in recommendations) / len(recommendations), 1) + if recommendations else 0 + ) + + recommended_mode = _choose_strategy_mode(temp, sectors, recommendations) + strategy_focus = _build_strategy_focus(temp, sectors, recommendations) + watch_sectors = [_sector_focus(s) for s in sectors[:5]] + avoid_rules = _build_avoid_rules(temp, sectors, recommendations) + iteration_notes = _build_iteration_notes(performance, recommendations) + + summary = ( + f"{market_regime},风险等级{risk_level}。" + f"当前 {len(recommendations)} 只入选,其中 {len(actionable)} 只可操作、" + f"{len(watch)} 只重点关注,平均分 {avg_score}。" + ) + + metrics = { + "temperature": temp, + "recommendation_count": len(recommendations), + "actionable_count": len(actionable), + "watch_count": len(watch), + "avg_score": avg_score, + "win_rate": performance.get("win_rate", 0), + "avg_return": performance.get("avg_return", 0), + "tracked": performance.get("tracked", 0), + } + + return StrategyBoard( + trade_date=trade_date, + market_regime=market_regime, + risk_level=risk_level, + action_bias=action_bias, + position_suggestion=position_suggestion, + summary=summary, + recommended_mode=recommended_mode, + strategy_focus=strategy_focus, + watch_sectors=watch_sectors, + avoid_rules=avoid_rules, + iteration_notes=iteration_notes, + metrics=metrics, + ) + + +def _classify_market( + temp: float, market_temp: MarketTemperature | None +) -> tuple[str, str, str, str]: + if temp >= 75: + return ("强势进攻", "低", "可积极关注主线龙头和突破确认", "单票 20%-30%,总仓 50%-70%") + if temp >= 60: + return ("修复偏强", "中低", "优先做早中期板块的突破/回踩确认", "单票 15%-25%,总仓 40%-60%") + if temp >= 45: + return ("震荡分化", "中", "只做板块一致性强的低吸或确认机会", "单票 10%-20%,总仓 25%-40%") + if temp >= 30: + return ("弱势防守", "中高", "以观察池为主,减少追高,只等强确认", "单票 0%-10%,总仓 0%-25%") + return ("退潮冰点", "高", "暂停主动出手,等待市场修复和主线重新出现", "空仓或极低仓观察") + + +def _choose_strategy_mode( + temp: float, sectors: list[SectorInfo], recommendations: list[Recommendation] +) -> str: + early_mid = [s for s in sectors[:5] if s.stage in ("early", "mid")] + if temp >= 60 and early_mid: + return "主线突破 + 回踩确认" + if temp >= 45: + return "精选回踩,降低追高" + if recommendations: + return "观察池跟踪,等待触发" + return "防守观察" + + +def _build_strategy_focus( + temp: float, sectors: list[SectorInfo], recommendations: list[Recommendation] +) -> list[StrategyFocus]: + focus: list[StrategyFocus] = [] + signal_counts: dict[str, int] = {} + for rec in recommendations: + signal_counts[rec.entry_signal_type] = signal_counts.get(rec.entry_signal_type, 0) + 1 + + top_signal = max(signal_counts, key=signal_counts.get) if signal_counts else "" + signal_label = { + "breakout": "突破型", + "breakout_confirm": "突破确认型", + "pullback": "回踩型", + "launch": "启动型", + "reversal": "反转型", + }.get(top_signal, "观察型") + + focus.append(StrategyFocus( + label=signal_label, + description=f"当前推荐中该类型占比较高,适合作为今日主要观察模板。", + )) + + if sectors: + main = sectors[0] + focus.append(StrategyFocus( + label=f"{main.sector_name} 主线跟踪", + description=f"热度 {main.heat_score},阶段 {main.stage},优先确认资金是否延续。", + )) + + if temp < 45: + focus.append(StrategyFocus( + label="防守优先", + description="市场温度不足,推荐只作为观察池,不宜扩大仓位。", + )) + + return focus + + +def _sector_focus(sector: SectorInfo) -> StrategySectorFocus: + stage_view = { + "early": "早期,重点观察资金是否连续流入", + "mid": "中期,适合寻找回踩或突破确认", + "late": "后期,防止加速后分歧", + "end": "末期,谨慎追高", + }.get(sector.stage, "阶段不明,等待确认") + + return StrategySectorFocus( + sector_name=sector.sector_name, + stage=sector.stage, + heat_score=sector.heat_score, + pct_change=sector.pct_change, + limit_up_count=sector.limit_up_count, + view=stage_view, + ) + + +def _build_avoid_rules( + temp: float, sectors: list[SectorInfo], recommendations: list[Recommendation] +) -> list[str]: + rules = [] + if temp < 45: + rules.append("市场温度低于45时,不追突破首日,只等次日确认或回踩。") + if any(s.stage == "end" for s in sectors[:5]): + rules.append("板块进入末期时,降低同板块追高标的权重。") + if any(r.position_score < 35 for r in recommendations): + rules.append("位置安全分低于35的标的,只观察不主动追入。") + if not rules: + rules.append("推荐失效条件触发后不补仓,等待下一次扫描重新确认。") + return rules + + +def _build_iteration_notes(performance: dict, recommendations: list[Recommendation]) -> list[str]: + notes = [] + tracked = performance.get("tracked", 0) or 0 + win_rate = performance.get("win_rate", 0) or 0 + avg_return = performance.get("avg_return", 0) or 0 + hit_stop = performance.get("hit_stop_count", 0) or 0 + hit_target = performance.get("hit_target_count", 0) or 0 + + if tracked < 10: + notes.append("跟踪样本不足,暂不自动调整策略权重,优先积累推荐生命周期数据。") + else: + if win_rate < 45: + notes.append("近期胜率偏低,下轮应提高入场确认门槛,减少弱势环境下的突破型推荐。") + if avg_return < 0: + notes.append("平均收益为负,建议收紧止损触发和推荐失效条件。") + if hit_stop > hit_target: + notes.append("止损次数多于命中目标,优先复查追高和板块末期惩罚是否不足。") + + actionable_count = sum(1 for r in recommendations if r.action_plan == "可操作") + if actionable_count > 5: + notes.append("可操作标的偏多,前端应按板块集中度和评分排序控制关注数量。") + + return notes + + +async def _generate_ai_review( + board: StrategyBoard, + recommendations: list[Recommendation], + performance: dict, +) -> str: + """用 LLM 生成简短的策略解释,不参与硬性交易决策。""" + from app.llm.client import chat_completion + + rec_lines = "\n".join( + f"- {r.name}({r.ts_code}) {r.action_plan} {r.entry_signal_type} " + f"评分{r.score} 仓位{r.suggested_position_pct}% 触发: {r.trigger_condition}" + for r in recommendations[:8] + ) or "暂无推荐" + + user_msg = f"""请基于以下系统数据,生成一段今日A股策略作战说明,要求: +1. 明确区分市场事实、策略推断和风险约束; +2. 不要承诺收益,不要给绝对化买卖结论; +3. 最多220字,中文。 + +市场状态: {board.market_regime} +风险等级: {board.risk_level} +操作倾向: {board.action_bias} +仓位建议: {board.position_suggestion} +推荐策略: {board.recommended_mode} +历史跟踪: 胜率{performance.get('win_rate', 0)}%, 平均收益{performance.get('avg_return', 0)}% + +推荐摘要: +{rec_lines} +""" + + resp = await chat_completion([ + {"role": "system", "content": "你是一位谨慎的A股交易研究助手,擅长把量化结果转成可执行但有风险边界的策略说明。"}, + {"role": "user", "content": user_msg}, + ]) + return resp.content.strip() if resp and resp.content else "" diff --git a/backend/app/llm/strategy_iteration.py b/backend/app/llm/strategy_iteration.py new file mode 100644 index 00000000..49264eb5 --- /dev/null +++ b/backend/app/llm/strategy_iteration.py @@ -0,0 +1,286 @@ +"""策略复盘迭代 Agent + +基于推荐生命周期表现,输出可审查的策略调整建议。 +不直接修改策略参数,只给出建议和证据。 +""" + +import json +import logging +from collections import defaultdict +from datetime import datetime + +from app.config import settings + +logger = logging.getLogger(__name__) + + +async def build_strategy_iteration_report(limit: int = 50, include_llm: bool = False) -> dict: + rows = await _load_recent_tracking(limit) + rule_report = _build_rule_report(rows) + + if include_llm and settings.deepseek_api_key and rows: + ai_text = await _generate_ai_iteration(rule_report, rows) + if ai_text: + rule_report["ai_analysis"] = ai_text + rule_report["generated_by"] = "rules+llm" + + return rule_report + + +async def _load_recent_tracking(limit: int) -> list[dict]: + from sqlalchemy import text + from app.db.database import get_db + + async with get_db() as db: + rec_columns = await _get_table_columns(db, "recommendations") + tracking_columns = await _get_table_columns(db, "recommendation_tracking") + r_action_plan = _column_or_default(rec_columns, "action_plan", "'观察'", "r") + r_position_score = _column_or_default(rec_columns, "position_score", "50", "r") + r_lifecycle_status = _column_or_default(rec_columns, "lifecycle_status", "'candidate'", "r") + t_max_return = _column_or_default(tracking_columns, "max_return_pct", "t.pct_from_entry", "t") + t_max_drawdown = _column_or_default(tracking_columns, "max_drawdown_pct", "t.pct_from_entry", "t") + t_days_since = _column_or_default(tracking_columns, "days_since_recommendation", "0", "t") + t_close_reason = _column_or_default(tracking_columns, "close_reason", "''", "t") + t_review_note = _column_or_default(tracking_columns, "review_note", "''", "t") + + result = await db.execute( + text( + "SELECT r.id, r.ts_code, r.name, r.sector, r.strategy, r.entry_signal_type, " + f"{r_action_plan} AS action_plan, r.score, r.market_temp_score, r.sector_score, " + f"{r_position_score} AS position_score, {r_lifecycle_status} AS lifecycle_status, r.created_at, " + f"t.pct_from_entry, {t_max_return} AS max_return_pct, {t_max_drawdown} AS max_drawdown_pct, " + f"{t_days_since} AS days_since_recommendation, {t_close_reason} AS close_reason, " + f"{t_review_note} AS review_note, t.track_date " + "FROM recommendations r " + "LEFT JOIN (" + " SELECT t.* 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" + ") t ON t.recommendation_id = r.id " + "ORDER BY r.created_at DESC LIMIT :limit" + ), + {"limit": limit}, + ) + return [dict(row._mapping) for row in result.fetchall()] + + +async def _get_table_columns(db, table_name: str) -> set[str]: + from sqlalchemy import text + + result = await db.execute(text(f"PRAGMA table_info({table_name})")) + return {row._mapping["name"] for row in result.fetchall()} + + +def _column_or_default(columns: set[str], column_name: str, default_sql: str, alias: str = "") -> str: + if column_name in columns: + return f"{alias}.{column_name}" if alias else column_name + return default_sql + + +def _build_rule_report(rows: list[dict]) -> dict: + if not rows: + return { + "generated_at": datetime.now().isoformat(), + "sample_size": 0, + "summary": "暂无可复盘的推荐样本。", + "strategy_stats": [], + "signal_stats": [], + "failure_patterns": ["样本不足,先积累推荐生命周期数据。"], + "adjustment_suggestions": [], + "ai_analysis": "", + "generated_by": "rules", + } + + tracked_rows = [r for r in rows if r.get("pct_from_entry") is not None] + strategy_stats = _group_stats(tracked_rows, "strategy") + signal_stats = _group_stats(tracked_rows, "entry_signal_type") + failure_patterns = _detect_failure_patterns(tracked_rows) + suggestions = _build_adjustment_suggestions(strategy_stats, signal_stats, failure_patterns, len(tracked_rows)) + + wins = sum(1 for r in tracked_rows if (r.get("pct_from_entry") or 0) > 0) + avg_return = _avg([r.get("pct_from_entry") for r in tracked_rows]) + avg_drawdown = _avg([r.get("max_drawdown_pct") for r in tracked_rows]) + win_rate = round(wins / len(tracked_rows) * 100, 1) if tracked_rows else 0 + + return { + "generated_at": datetime.now().isoformat(), + "sample_size": len(tracked_rows), + "summary": ( + f"最近 {len(rows)} 条推荐中,{len(tracked_rows)} 条已有跟踪数据;" + f"胜率 {win_rate}%,平均收益 {avg_return}%,平均最大回撤 {avg_drawdown}%。" + ), + "strategy_stats": strategy_stats, + "signal_stats": signal_stats, + "failure_patterns": failure_patterns, + "adjustment_suggestions": suggestions, + "ai_analysis": "", + "generated_by": "rules", + } + + +def _group_stats(rows: list[dict], key: str) -> list[dict]: + groups: dict[str, list[dict]] = defaultdict(list) + for row in rows: + groups[row.get(key) or "unknown"].append(row) + + stats = [] + for name, items in groups.items(): + wins = sum(1 for r in items if (r.get("pct_from_entry") or 0) > 0) + hit_stop = sum(1 for r in items if r.get("close_reason") == "hit_stop_loss") + hit_target = sum(1 for r in items if r.get("close_reason") == "hit_target") + stats.append({ + "name": name, + "count": len(items), + "win_rate": round(wins / len(items) * 100, 1) if items else 0, + "avg_return": _avg([r.get("pct_from_entry") for r in items]), + "avg_max_return": _avg([r.get("max_return_pct") for r in items]), + "avg_max_drawdown": _avg([r.get("max_drawdown_pct") for r in items]), + "hit_target": hit_target, + "hit_stop": hit_stop, + }) + + stats.sort(key=lambda x: (x["count"], x["avg_return"]), reverse=True) + return stats + + +def _detect_failure_patterns(rows: list[dict]) -> list[str]: + patterns = [] + if not rows: + return ["暂无跟踪样本。"] + + weak_market_losses = [ + r for r in rows + if (r.get("market_temp_score") or 0) < 45 and (r.get("pct_from_entry") or 0) < 0 + ] + if len(weak_market_losses) >= 2: + patterns.append("弱势市场中仍有亏损推荐,低温环境下应进一步减少 BUY 或提高确认门槛。") + + high_position_losses = [ + r for r in rows + if (r.get("position_score") or 50) < 40 and (r.get("pct_from_entry") or 0) < 0 + ] + if len(high_position_losses) >= 2: + patterns.append("位置安全分偏低的推荐亏损较多,追高惩罚需要增强。") + + stop_losses = [r for r in rows if r.get("close_reason") == "hit_stop_loss"] + if len(stop_losses) >= 2: + patterns.append("触发止损样本偏多,需要复查止损位置和入场触发条件是否过宽。") + + expired_flat = [ + r for r in rows + if r.get("close_reason") in ("review_expired_flat", "review_expired_loss") + ] + if len(expired_flat) >= 3: + patterns.append("多只推荐到期未形成有效进攻,观察池转可操作的条件需要更严格。") + + if not patterns: + patterns.append("暂无明显集中失败模式,继续积累样本并按策略分组观察。") + return patterns + + +def _build_adjustment_suggestions( + strategy_stats: list[dict], + signal_stats: list[dict], + failure_patterns: list[str], + sample_size: int, +) -> list[dict]: + suggestions = [] + + if sample_size < 10: + return [{ + "target": "全局策略", + "action": "observe", + "reason": "跟踪样本少于10条,暂不建议调整参数。", + "confidence": "low", + }] + + for stat in strategy_stats: + if stat["count"] >= 3 and stat["win_rate"] < 40 and stat["avg_return"] < 0: + suggestions.append({ + "target": stat["name"], + "action": "tighten", + "reason": f"{stat['name']} 胜率{stat['win_rate']}%,平均收益{stat['avg_return']}%,建议提高买入门槛。", + "confidence": "medium", + }) + elif stat["count"] >= 3 and stat["win_rate"] >= 60 and stat["avg_return"] > 1: + suggestions.append({ + "target": stat["name"], + "action": "promote", + "reason": f"{stat['name']} 近期表现较好,可在相似市场环境下优先使用。", + "confidence": "medium", + }) + + for stat in signal_stats: + if stat["count"] >= 3 and stat["avg_max_drawdown"] < -5: + suggestions.append({ + "target": stat["name"], + "action": "reduce", + "reason": f"{stat['name']} 平均最大回撤{stat['avg_max_drawdown']}%,建议降低排序权重或增加位置过滤。", + "confidence": "medium", + }) + + if any("弱势市场" in p for p in failure_patterns): + suggestions.append({ + "target": "defensive_watch", + "action": "tighten", + "reason": "弱势市场亏损样本集中,防守策略下应只保留观察池,减少 BUY。", + "confidence": "high", + }) + + if not suggestions: + suggestions.append({ + "target": "全局策略", + "action": "keep", + "reason": "当前样本未显示需要立即调整的集中问题。", + "confidence": "medium", + }) + + return suggestions[:6] + + +async def _generate_ai_iteration(rule_report: dict, rows: list[dict]) -> str: + from app.llm.client import chat_completion + + sample = [ + { + "name": r.get("name"), + "strategy": r.get("strategy"), + "signal": r.get("entry_signal_type"), + "return": r.get("pct_from_entry"), + "max_return": r.get("max_return_pct"), + "drawdown": r.get("max_drawdown_pct"), + "reason": r.get("close_reason"), + "market_temp": r.get("market_temp_score"), + "position_score": r.get("position_score"), + } + for r in rows[:20] + ] + + user_msg = f"""请基于以下推荐复盘数据,输出策略迭代建议。 +要求: +1. 明确指出最该收紧、保留、加强的策略或信号; +2. 只提出可执行调整建议,不要泛泛而谈; +3. 不要承诺收益; +4. 180字以内。 + +规则复盘: +{json.dumps(rule_report, ensure_ascii=False)} + +样本: +{json.dumps(sample, ensure_ascii=False)} +""" + + resp = await chat_completion([ + {"role": "system", "content": "你是一位A股策略复盘研究员,负责基于推荐结果提出保守、可验证的策略迭代建议。"}, + {"role": "user", "content": user_msg}, + ]) + return resp.content.strip() if resp and resp.content else "" + + +def _avg(values: list) -> float: + clean = [float(v) for v in values if v is not None] + if not clean: + return 0 + return round(sum(clean) / len(clean), 2) diff --git a/backend/app/llm/strategy_selector.py b/backend/app/llm/strategy_selector.py new file mode 100644 index 00000000..f1c9c53d --- /dev/null +++ b/backend/app/llm/strategy_selector.py @@ -0,0 +1,211 @@ +"""动态策略选择器 + +在固定筛选引擎前增加一层“先选打法,再选股票”的策略决策。 +规则负责稳定分类,LLM 负责补充语义判断和操作建议。 +""" + +import json +import logging + +from pydantic import BaseModel + +from app.config import settings +from app.data.models import MarketTemperature, SectorInfo + +logger = logging.getLogger(__name__) + + +class StrategyProfile(BaseModel): + strategy_id: str + name: str + description: str + entry_signal_priority: list[str] + score_weights: dict[str, float] + min_score: float + buy_threshold: float + max_position_pct: float + notes: list[str] = [] + generated_by: str = "rules" + + +async def select_strategy_profile( + market_temp: MarketTemperature | None, + hot_sectors: list[SectorInfo], + intraday: bool, +) -> StrategyProfile: + profile = _select_rule_profile(market_temp, hot_sectors, intraday) + + if settings.deepseek_api_key: + llm_profile = await _select_llm_profile(market_temp, hot_sectors, intraday, profile) + if llm_profile: + profile = llm_profile + + return profile + + +def _select_rule_profile( + market_temp: MarketTemperature | None, + hot_sectors: list[SectorInfo], + intraday: bool, +) -> StrategyProfile: + temp = market_temp.temperature if market_temp else 0 + early_count = sum(1 for s in hot_sectors[:5] if s.stage == "early") + late_count = sum(1 for s in hot_sectors[:5] if s.stage in ("late", "end")) + + if temp >= 65 and early_count >= 1: + return StrategyProfile( + strategy_id="breakout_attack", + name="主线突破", + description="市场偏强,优先寻找主线板块内的突破和突破确认。", + entry_signal_priority=["breakout", "breakout_confirm", "launch", "pullback", "reversal"], + score_weights={"supply_demand": 0.45, "price_action": 0.35, "trend": 0.20}, + min_score=62, + buy_threshold=66, + max_position_pct=30, + notes=["优先做主线早中期板块", "放量突破优先于回踩低吸"], + ) + + if temp >= 45 and late_count < 2: + return StrategyProfile( + strategy_id="pullback_rotation", + name="回踩轮动", + description="市场震荡分化,优先做回踩支撑和板块轮动中的低吸确认。", + entry_signal_priority=["pullback", "breakout_confirm", "launch", "breakout", "reversal"], + score_weights={"supply_demand": 0.40, "price_action": 0.30, "trend": 0.30}, + min_score=60, + buy_threshold=63, + max_position_pct=20, + notes=["降低追高仓位", "更看重位置安全和回踩承接"], + ) + + if temp >= 30: + return StrategyProfile( + strategy_id="launch_probe", + name="启动试错", + description="市场偏弱,适合少量观察启动型和反转型机会,不做强追涨。", + entry_signal_priority=["launch", "reversal", "pullback", "breakout_confirm", "breakout"], + score_weights={"supply_demand": 0.35, "price_action": 0.35, "trend": 0.30}, + min_score=58, + buy_threshold=61, + max_position_pct=10, + notes=["仅做小仓位试错", "突破型需要更强板块一致性才可介入"], + ) + + return StrategyProfile( + strategy_id="defensive_watch", + name="防守观察", + description="市场退潮,系统以观察池为主,不主动扩大出手。", + entry_signal_priority=["pullback", "launch", "reversal", "breakout_confirm", "breakout"], + score_weights={"supply_demand": 0.35, "price_action": 0.40, "trend": 0.25}, + min_score=56, + buy_threshold=64, + max_position_pct=5, + notes=["原则上只保留观察池", "等待市场温度修复后再转入主动进攻"], + ) + + +async def _select_llm_profile( + market_temp: MarketTemperature | None, + hot_sectors: list[SectorInfo], + intraday: bool, + fallback: StrategyProfile, +) -> StrategyProfile | None: + from app.llm.client import chat_completion + + sector_text = "\n".join( + f"- {s.sector_name}: 涨幅{s.pct_change}%, 热度{s.heat_score}, 阶段{s.stage}, 涨停{s.limit_up_count}" + for s in hot_sectors[:5] + ) or "暂无板块数据" + + user_msg = f"""你需要为今日A股环境选择一个短线策略模板。 + +市场温度: {market_temp.temperature if market_temp else 0} +上涨家数: {market_temp.up_count if market_temp else 0} +下跌家数: {market_temp.down_count if market_temp else 0} +涨停数: {market_temp.limit_up_count if market_temp else 0} +炸板率: {market_temp.broken_rate if market_temp else 0} +盘中模式: {'是' if intraday else '否'} + +热门板块: +{sector_text} + +规则候选策略: +- breakout_attack: 主线突破 +- pullback_rotation: 回踩轮动 +- launch_probe: 启动试错 +- defensive_watch: 防守观察 + +请输出 JSON,格式: +{{ + "strategy_id": "上面四选一", + "notes": ["两条以内理由"], + "buy_threshold_delta": -3到3之间的整数 +}} +""" + + resp = await chat_completion([ + {"role": "system", "content": "你是一位A股短线策略研究员,只能在给定策略模板中选择,不要发明新策略。回复必须是 JSON。"}, + {"role": "user", "content": user_msg}, + ]) + if not resp or not resp.content: + return None + + try: + data = json.loads(resp.content) + strategy_id = data.get("strategy_id") + if strategy_id not in {"breakout_attack", "pullback_rotation", "launch_probe", "defensive_watch"}: + return None + selected = _select_rule_profile(market_temp, hot_sectors, intraday) + if selected.strategy_id != strategy_id: + selected = { + "breakout_attack": StrategyProfile( + strategy_id="breakout_attack", + name="主线突破", + description="市场偏强,优先寻找主线板块内的突破和突破确认。", + entry_signal_priority=["breakout", "breakout_confirm", "launch", "pullback", "reversal"], + score_weights={"supply_demand": 0.45, "price_action": 0.35, "trend": 0.20}, + min_score=62, + buy_threshold=66, + max_position_pct=30, + ), + "pullback_rotation": StrategyProfile( + strategy_id="pullback_rotation", + name="回踩轮动", + description="市场震荡分化,优先做回踩支撑和板块轮动中的低吸确认。", + entry_signal_priority=["pullback", "breakout_confirm", "launch", "breakout", "reversal"], + score_weights={"supply_demand": 0.40, "price_action": 0.30, "trend": 0.30}, + min_score=60, + buy_threshold=63, + max_position_pct=20, + ), + "launch_probe": StrategyProfile( + strategy_id="launch_probe", + name="启动试错", + description="市场偏弱,适合少量观察启动型和反转型机会,不做强追涨。", + entry_signal_priority=["launch", "reversal", "pullback", "breakout_confirm", "breakout"], + score_weights={"supply_demand": 0.35, "price_action": 0.35, "trend": 0.30}, + min_score=58, + buy_threshold=61, + max_position_pct=10, + ), + "defensive_watch": StrategyProfile( + strategy_id="defensive_watch", + name="防守观察", + description="市场退潮,系统以观察池为主,不主动扩大出手。", + entry_signal_priority=["pullback", "launch", "reversal", "breakout_confirm", "breakout"], + score_weights={"supply_demand": 0.35, "price_action": 0.40, "trend": 0.25}, + min_score=56, + buy_threshold=64, + max_position_pct=5, + ), + }[strategy_id] + + delta = int(data.get("buy_threshold_delta", 0)) + delta = max(-3, min(3, delta)) + selected.buy_threshold += delta + selected.notes.extend(data.get("notes", [])[:2]) + selected.generated_by = "rules+llm" + return selected + except Exception as e: + logger.debug(f"LLM 策略选择解析失败: {e}") + return fallback diff --git a/backend/app/llm/tool_executor.py b/backend/app/llm/tool_executor.py index 4444493c..7bfc2419 100644 --- a/backend/app/llm/tool_executor.py +++ b/backend/app/llm/tool_executor.py @@ -9,16 +9,27 @@ import math logger = logging.getLogger(__name__) +_chat_user_context: dict | None = None + + +def set_chat_user_context(user: dict | None) -> None: + global _chat_user_context + _chat_user_context = user + async def execute_tool(name: str, arguments: dict) -> str: """执行工具调用,返回 JSON 字符串""" try: - if name == "get_market_temperature": + if name == "get_strategy_board": + return await _get_strategy_board() + elif name == "get_market_temperature": return await _get_market_temperature() elif name == "get_hot_sectors": return await _get_hot_sectors(arguments.get("limit", 10)) elif name == "get_latest_recommendations": return await _get_latest_recommendations() + elif name == "get_user_watchlist_snapshot": + return await _get_user_watchlist_snapshot() elif name == "get_stock_kline": return await _get_stock_kline( arguments["ts_code"], arguments.get("days", 60) @@ -53,6 +64,28 @@ def _clean_for_json(obj): return obj +async def _get_strategy_board() -> str: + from app.llm.strategy_board import build_strategy_board + + board = await build_strategy_board(include_llm=False) + payload = { + "trade_date": board.get("trade_date", ""), + "market_regime": board.get("market_regime", ""), + "risk_level": board.get("risk_level", ""), + "action_bias": board.get("action_bias", ""), + "position_suggestion": board.get("position_suggestion", ""), + "summary": board.get("summary", ""), + "recommended_mode": board.get("recommended_mode", ""), + "watch_sectors": board.get("watch_sectors", [])[:5], + "strategy_focus": board.get("strategy_focus", [])[:4], + "avoid_rules": board.get("avoid_rules", [])[:4], + "iteration_notes": board.get("iteration_notes", [])[:3], + "metrics": board.get("metrics", {}), + "generated_by": board.get("generated_by", "rules"), + } + return json.dumps(_clean_for_json(payload), ensure_ascii=False, default=str) + + async def _get_market_temperature() -> str: from app.engine.recommender import get_latest_recommendations result = await get_latest_recommendations() @@ -73,10 +106,64 @@ async def _get_latest_recommendations() -> str: from app.engine.recommender import get_latest_recommendations result = await get_latest_recommendations() recs = result.get("recommendations", []) - data = [r.model_dump(exclude={"created_at"}) for r in recs] + data = [] + for rec in recs: + item = rec.model_dump(exclude={"created_at"}) + item["llm_analysis"] = "" + data.append(item) return json.dumps(data, ensure_ascii=False, default=str) +async def _get_user_watchlist_snapshot() -> str: + from sqlalchemy import text + from app.db.database import get_db + + user_id = (_chat_user_context or {}).get("id") + if not user_id: + return json.dumps({"error": "当前会话缺少用户上下文"}, ensure_ascii=False) + + async with get_db() as db: + rows = (await db.execute( + text( + "SELECT w.id, w.ts_code, w.name, w.note, w.watch_group, w.cost_price, w.updated_at, " + "a.conclusion, a.advice, a.trigger_condition, a.risk_note, a.summary, " + "a.analysis_mode, a.created_at AS analysis_created_at " + "FROM user_watchlists w " + "LEFT JOIN watchlist_analyses a ON a.id = (" + " SELECT id FROM watchlist_analyses " + " WHERE watchlist_id = w.id ORDER BY created_at DESC, id DESC LIMIT 1" + ") " + "WHERE w.user_id = :uid AND COALESCE(w.is_active, 1) = 1 " + "ORDER BY CASE w.watch_group " + " WHEN 'focus' THEN 1 " + " WHEN 'candidate' THEN 2 " + " WHEN 'holding' THEN 3 " + " ELSE 4 END, w.updated_at DESC, w.id DESC" + ), + {"uid": user_id}, + )).fetchall() + + items = [dict(row._mapping) for row in rows] + grouped = {"focus": 0, "candidate": 0, "holding": 0, "observe": 0} + for item in items: + key = item.get("watch_group") or "observe" + if key in grouped: + grouped[key] += 1 + + actionable = [ + item for item in items + if item.get("conclusion") in {"可操作", "重点关注"} + ][:8] + + payload = { + "count": len(items), + "group_counts": grouped, + "high_priority": actionable, + "items": items[:20], + } + return json.dumps(_clean_for_json(payload), ensure_ascii=False, default=str) + + async def _get_stock_kline(ts_code: str, days: int) -> str: from app.data.tushare_client import tushare_client from app.analysis.technical import add_all_indicators diff --git a/backend/app/llm/tools.py b/backend/app/llm/tools.py index 85247ca7..c7d2c0bd 100644 --- a/backend/app/llm/tools.py +++ b/backend/app/llm/tools.py @@ -4,6 +4,18 @@ """ CHAT_TOOLS = [ + { + "type": "function", + "function": { + "name": "get_strategy_board", + "description": "获取今日作战结论,包括市场状态、今日打法、建议仓位、重点板块和规避规则", + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, + }, + }, { "type": "function", "function": { @@ -45,6 +57,18 @@ CHAT_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "get_user_watchlist_snapshot", + "description": "获取当前用户自选股概览,包括分组、最新结论、建议、触发条件和摘要", + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, + }, + }, { "type": "function", "function": { diff --git a/backend/app/main.py b/backend/app/main.py index e11434ef..b3c2a7b6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.config import settings from app.db.database import init_db from app.engine.scheduler import start_scheduler, stop_scheduler -from app.api import market, sectors, recommendations, stocks, websocket, chat, auth, debug +from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, debug logging.basicConfig( level=logging.DEBUG if settings.debug else logging.INFO, @@ -78,6 +78,7 @@ app.include_router(market.router) app.include_router(sectors.router) app.include_router(recommendations.router) app.include_router(stocks.router) +app.include_router(watchlists.router) app.include_router(chat.router) app.include_router(auth.router) app.include_router(debug.router) diff --git a/backend/astock.db b/backend/astock.db index e1b0e525962f0e3898fb281cbab82a9262757557..31ffc2d15593d63624abff6a3595d7544766d392 100644 GIT binary patch delta 136644 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 delta 72224 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%0DVN|7~=Ye#xw4ht(`+(LJa5RI&` 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 C&%G@G diff --git a/frontend/.next/app-build-manifest.json b/frontend/.next/app-build-manifest.json index 0ec4fa49..bce6a549 100644 --- a/frontend/.next/app-build-manifest.json +++ b/frontend/.next/app-build-manifest.json @@ -1,5 +1,11 @@ { "pages": { + "/layout": [ + "static/chunks/webpack.js", + "static/chunks/main-app.js", + "static/css/app/layout.css", + "static/chunks/app/layout.js" + ], "/(auth)/dashboard/page": [ "static/chunks/webpack.js", "static/chunks/main-app.js", @@ -10,22 +16,61 @@ "static/chunks/main-app.js", "static/chunks/app/(auth)/layout.js" ], - "/layout": [ - "static/chunks/webpack.js", - "static/chunks/main-app.js", - "static/css/app/layout.css", - "static/chunks/app/layout.js" - ], "/(auth)/recommendations/page": [ "static/chunks/webpack.js", "static/chunks/main-app.js", "static/chunks/app/(auth)/recommendations/page.js" ], + "/(public)/layout": [ + "static/chunks/webpack.js", + "static/chunks/main-app.js", + "static/chunks/app/(public)/layout.js" + ], + "/(auth)/strategy/page": [ + "static/chunks/webpack.js", + "static/chunks/main-app.js", + "static/chunks/app/(auth)/strategy/page.js" + ], + "/(auth)/sectors/page": [ + "static/chunks/webpack.js", + "static/chunks/main-app.js", + "static/chunks/app/(auth)/sectors/page.js" + ], + "/(auth)/diagnose/page": [ + "static/chunks/webpack.js", + "static/chunks/main-app.js", + "static/chunks/app/(auth)/diagnose/page.js" + ], + "/(auth)/stock/[code]/page": [ + "static/chunks/webpack.js", + "static/chunks/main-app.js", + "static/chunks/app/(auth)/stock/[code]/page.js" + ], "/(auth)/settings/page": [ "static/chunks/webpack.js", "static/chunks/main-app.js", "static/chunks/app/(auth)/settings/page.js" ], + "/(auth)/watchlists/page": [ + "static/chunks/webpack.js", + "static/chunks/main-app.js", + "static/chunks/app/(auth)/watchlists/page.js" + ], + "/(public)/login/page": [ + "static/chunks/webpack.js", + "static/chunks/main-app.js", + "static/chunks/app/(public)/login/page.js" + ], + "/(auth)/chat/page": [ + "static/chunks/webpack.js", + "static/chunks/main-app.js", + "static/chunks/app/(auth)/chat/page.js" + ], + "/(public)/page": [ + "static/chunks/webpack.js", + "static/chunks/main-app.js", + "static/chunks/app/(public)/page.js" + ], "/_not-found/page": [ "static/chunks/webpack.js", "static/chunks/main-app.js", diff --git a/frontend/.next/build-manifest.json b/frontend/.next/build-manifest.json index 018cb67f..b4e9156a 100644 --- a/frontend/.next/build-manifest.json +++ b/frontend/.next/build-manifest.json @@ -2,7 +2,9 @@ "polyfillFiles": [ "static/chunks/polyfills.js" ], - "devFiles": [], + "devFiles": [ + "static/chunks/react-refresh.js" + ], "ampDevFiles": [], "lowPriorityFiles": [ "static/development/_buildManifest.js", @@ -13,7 +15,16 @@ "static/chunks/main-app.js" ], "pages": { - "/_app": [] + "/_app": [ + "static/chunks/webpack.js", + "static/chunks/main.js", + "static/chunks/pages/_app.js" + ], + "/_error": [ + "static/chunks/webpack.js", + "static/chunks/main.js", + "static/chunks/pages/_error.js" + ] }, "ampFirstPages": [] } \ No newline at end of file diff --git a/frontend/.next/react-loadable-manifest.json b/frontend/.next/react-loadable-manifest.json index 9e26dfee..708968d6 100644 --- a/frontend/.next/react-loadable-manifest.json +++ b/frontend/.next/react-loadable-manifest.json @@ -1 +1,20 @@ -{} \ No newline at end of file +{ + "app/(auth)/sectors/page.tsx -> echarts": { + "id": "app/(auth)/sectors/page.tsx -> echarts", + "files": [ + "static/chunks/_app-pages-browser_node_modules_echarts_index_js.js" + ] + }, + "components/capital-flow.tsx -> echarts": { + "id": "components/capital-flow.tsx -> echarts", + "files": [ + "static/chunks/_app-pages-browser_node_modules_echarts_index_js.js" + ] + }, + "components/kline-chart.tsx -> echarts": { + "id": "components/kline-chart.tsx -> echarts", + "files": [ + "static/chunks/_app-pages-browser_node_modules_echarts_index_js.js" + ] + } +} \ No newline at end of file diff --git a/frontend/.next/server/app-paths-manifest.json b/frontend/.next/server/app-paths-manifest.json index d8cf5ff6..32c12767 100644 --- a/frontend/.next/server/app-paths-manifest.json +++ b/frontend/.next/server/app-paths-manifest.json @@ -1,6 +1,8 @@ { "/_not-found/page": "app/_not-found/page.js", "/(auth)/dashboard/page": "app/(auth)/dashboard/page.js", - "/(auth)/recommendations/page": "app/(auth)/recommendations/page.js", - "/(auth)/settings/page": "app/(auth)/settings/page.js" + "/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js", + "/(auth)/chat/page": "app/(auth)/chat/page.js", + "/(public)/login/page": "app/(public)/login/page.js", + "/(public)/page": "app/(public)/page.js" } \ No newline at end of file diff --git a/frontend/.next/server/interception-route-rewrite-manifest.js b/frontend/.next/server/interception-route-rewrite-manifest.js index 24f77ba7..82d3ab17 100644 --- a/frontend/.next/server/interception-route-rewrite-manifest.js +++ b/frontend/.next/server/interception-route-rewrite-manifest.js @@ -1 +1 @@ -self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"; \ No newline at end of file +self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]" \ No newline at end of file diff --git a/frontend/.next/server/middleware-build-manifest.js b/frontend/.next/server/middleware-build-manifest.js index 36489d8c..424a1a19 100644 --- a/frontend/.next/server/middleware-build-manifest.js +++ b/frontend/.next/server/middleware-build-manifest.js @@ -2,7 +2,9 @@ self.__BUILD_MANIFEST = { "polyfillFiles": [ "static/chunks/polyfills.js" ], - "devFiles": [], + "devFiles": [ + "static/chunks/react-refresh.js" + ], "ampDevFiles": [], "lowPriorityFiles": [], "rootMainFiles": [ @@ -10,7 +12,16 @@ self.__BUILD_MANIFEST = { "static/chunks/main-app.js" ], "pages": { - "/_app": [] + "/_app": [ + "static/chunks/webpack.js", + "static/chunks/main.js", + "static/chunks/pages/_app.js" + ], + "/_error": [ + "static/chunks/webpack.js", + "static/chunks/main.js", + "static/chunks/pages/_error.js" + ] }, "ampFirstPages": [] }; diff --git a/frontend/.next/server/middleware-react-loadable-manifest.js b/frontend/.next/server/middleware-react-loadable-manifest.js index ca34f09f..679c4feb 100644 --- a/frontend/.next/server/middleware-react-loadable-manifest.js +++ b/frontend/.next/server/middleware-react-loadable-manifest.js @@ -1 +1 @@ -self.__REACT_LOADABLE_MANIFEST="{}" \ No newline at end of file +self.__REACT_LOADABLE_MANIFEST="{\"app/(auth)/sectors/page.tsx -> echarts\":{\"id\":\"app/(auth)/sectors/page.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/capital-flow.tsx -> echarts\":{\"id\":\"components/capital-flow.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/kline-chart.tsx -> echarts\":{\"id\":\"components/kline-chart.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}" \ No newline at end of file diff --git a/frontend/.next/server/pages-manifest.json b/frontend/.next/server/pages-manifest.json index 9e26dfee..a679766a 100644 --- a/frontend/.next/server/pages-manifest.json +++ b/frontend/.next/server/pages-manifest.json @@ -1 +1,5 @@ -{} \ No newline at end of file +{ + "/_app": "pages/_app.js", + "/_error": "pages/_error.js", + "/_document": "pages/_document.js" +} \ No newline at end of file diff --git a/frontend/.next/server/server-reference-manifest.json b/frontend/.next/server/server-reference-manifest.json index cbf0b4da..c5c83141 100644 --- a/frontend/.next/server/server-reference-manifest.json +++ b/frontend/.next/server/server-reference-manifest.json @@ -1,5 +1,5 @@ { "node": {}, "edge": {}, - "encryptionKey": "f4eykmt9lLjeIDNHjaA0ZKJupk05dXT0k2cBaExPwP8=" + "encryptionKey": "5a77t1jXySke+j0Es8vduY/7S7yObSbYfKeh0OReITs=" } \ No newline at end of file diff --git a/frontend/src/app/(auth)/chat/page.tsx b/frontend/src/app/(auth)/chat/page.tsx index af32c45a..c9305013 100644 --- a/frontend/src/app/(auth)/chat/page.tsx +++ b/frontend/src/app/(auth)/chat/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState, useRef, useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTheme } from "next-themes"; -import { streamChat, type ChatMessage } from "@/lib/api"; import { formatMarkdown } from "@/lib/markdown"; +import { streamChat, type ChatMessage } from "@/lib/api"; interface DisplayMessage { role: "user" | "assistant"; @@ -11,9 +11,25 @@ interface DisplayMessage { } const QUICK_QUESTIONS = [ - "今日市场怎么样?", - "有哪些推荐股票?", - "哪些板块最热门?", + "结合今日作战结论,告诉我今天应该重点看什么。", + "把当前推荐池分成可操作、重点关注和仅观察三层讲给我。", + "看看我的自选股里哪些需要明天优先盯盘。", + "如果今天只允许做一个方向,你建议我盯哪个主线,为什么?", +]; + +const CHAT_SCENES = [ + { + title: "问今日打法", + description: "把今日结论翻译成人话,说明现在该进攻、试错还是防守。", + }, + { + title: "问推荐池", + description: "追问某只推荐股为什么进池、什么条件下能看、什么条件下放弃。", + }, + { + title: "问自选股", + description: "围绕你自己的观察池、候选池和持仓池做连续追问。", + }, ]; export default function ChatPage() { @@ -37,42 +53,41 @@ export default function ChatPage() { }, [messages, status]); const sendMessage = async (text: string) => { - if (!text.trim() || streaming) return; + const content = text.trim(); + if (!content || streaming) return; - const userMsg: DisplayMessage = { role: "user", content: text.trim() }; + const userMsg: DisplayMessage = { role: "user", content }; const newMessages = [...messages, userMsg]; - setMessages(newMessages); + + setMessages([...newMessages, { role: "assistant", content: "" }]); setInput(""); setStreaming(true); setStatus(""); - // Add empty assistant message for streaming - setMessages([...newMessages, { role: "assistant", content: "" }]); - try { - const chatMessages: ChatMessage[] = newMessages.map((m) => ({ - role: m.role, - content: m.content, + const chatMessages: ChatMessage[] = newMessages.map((message) => ({ + role: message.role, + content: message.content, })); let fullContent = ""; for await (const event of streamChat(chatMessages)) { if (event.type === "status") { setStatus(event.content); - } else if (event.type === "content") { - fullContent += event.content; - setMessages([ - ...newMessages, - { role: "assistant", content: fullContent }, - ]); - setStatus(""); + continue; } + + fullContent += event.content; + setMessages([ + ...newMessages, + { role: "assistant", content: fullContent }, + ]); } - } catch (e) { - console.error("Chat error:", e); + } catch (error) { + console.error("Chat error:", error); setMessages([ ...newMessages, - { role: "assistant", content: "连接失败,请检查网络后重试。" }, + { role: "assistant", content: "连接失败,暂时无法读取作战数据,请稍后重试。" }, ]); } finally { setStreaming(false); @@ -80,140 +95,177 @@ export default function ChatPage() { } }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); sendMessage(input); } }; return ( -
    - {/* Header */} -
    -
    -
    - - - -
    -
    -

    AI 投资顾问

    -

    - 基于实时市场数据的智能问答 -

    -
    -
    - {messages.length > 0 && ( - - )} -
    - - {/* Messages */} -
    - {messages.length === 0 ? ( -
    -
    - - - +
    +
    + + +
    +
    +
    +
    + + + +
    +
    +

    围绕作战结果继续追问

    +

    + 今日作战 / 推荐池 / 自选股 / 主线板块 +

    +
    +
    + + {messages.length > 0 ? ( + + ) : null} +
    + +
    + {messages.length === 0 ? ( +
    +
    +
    + + + +
    +

    先用它来拆解系统已经给出的结论

    +

    + 这不是泛用 AI 问答框。更好的用法是直接追问今天该怎么打、推荐池为什么这样分层、你的自选股哪些该提级或降级。 +

    +
    + +
    + {QUICK_QUESTIONS.map((question) => ( + + ))}
    - ))} - {/* Status indicator during tool calls */} - {streaming && status && messages[messages.length - 1]?.content && ( -
    -
    - - {status} -
    + ) : ( +
    + {messages.map((message, index) => ( +
    +
    + {message.role === "assistant" ? ( + message.content ? ( +
    + ) : ( + {status || "读取作战上下文中..."} + ) + ) : ( + {message.content} + )} + {streaming && index === messages.length - 1 && message.role === "assistant" && message.content ? ( + + ) : null} +
    +
    + ))} + + {streaming && status && messages[messages.length - 1]?.content ? ( +
    +
    + + {status} +
    +
    + ) : null}
    )} - - )} -
    +
    - {/* Input */} -
    -
    -