最大更新
This commit is contained in:
parent
865db50369
commit
b699b185fc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
return StreamingResponse(_stream_diagnosis(), media_type="text/event-stream")
|
||||
|
||||
250
backend/app/api/watchlists.py
Normal file
250
backend/app/api/watchlists.py
Normal file
@ -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]
|
||||
Binary file not shown.
@ -133,7 +133,46 @@ class Recommendation(BaseModel):
|
||||
strategy: str = "trend_breakout" # trend_breakout / momentum(旧) / potential(旧)
|
||||
entry_signal_type: str = "none" # breakout / pullback / launch / none
|
||||
entry_timing: str = "" # 进场时机建议(盘中适用)
|
||||
action_plan: str = "观察" # 可操作 / 重点关注 / 观察
|
||||
trigger_condition: str = "" # 触发条件
|
||||
invalidation_condition: str = "" # 失效条件
|
||||
suggested_position_pct: float = 0 # 建议仓位 %
|
||||
review_after_days: int = 3 # 建议复盘天数
|
||||
lifecycle_status: str = "candidate" # candidate / actionable / tracking / closed
|
||||
data_freshness: str = "" # 数据新鲜度说明
|
||||
llm_analysis: str = "" # LLM 深度分析
|
||||
llm_score: float | None = None # AI 评分 1-10
|
||||
scan_session: str = ""
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class StrategyFocus(BaseModel):
|
||||
label: str
|
||||
description: str
|
||||
|
||||
|
||||
class StrategySectorFocus(BaseModel):
|
||||
sector_name: str
|
||||
stage: str = "mid"
|
||||
heat_score: float = 0
|
||||
pct_change: float = 0
|
||||
limit_up_count: int = 0
|
||||
view: str = ""
|
||||
|
||||
|
||||
class StrategyBoard(BaseModel):
|
||||
trade_date: str
|
||||
market_regime: str
|
||||
risk_level: str
|
||||
action_bias: str
|
||||
position_suggestion: str
|
||||
summary: str
|
||||
recommended_mode: str
|
||||
strategy_focus: list[StrategyFocus] = []
|
||||
watch_sectors: list[StrategySectorFocus] = []
|
||||
avoid_rules: list[str] = []
|
||||
iteration_notes: list[str] = []
|
||||
iteration_report: dict = {}
|
||||
metrics: dict = {}
|
||||
ai_review: str = ""
|
||||
generated_by: str = "rules"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -40,12 +40,21 @@ async def init_db():
|
||||
"ALTER TABLE recommendations ADD COLUMN price_action_score REAL DEFAULT 0",
|
||||
"ALTER TABLE recommendations ADD COLUMN position_score REAL",
|
||||
"ALTER TABLE recommendations ADD COLUMN valuation_score REAL",
|
||||
"ALTER TABLE recommendations ADD COLUMN risk_note TEXT DEFAULT ''",
|
||||
"ALTER TABLE recommendations ADD COLUMN action_plan TEXT DEFAULT '观察'",
|
||||
"ALTER TABLE recommendations ADD COLUMN trigger_condition TEXT DEFAULT ''",
|
||||
"ALTER TABLE recommendations ADD COLUMN invalidation_condition TEXT DEFAULT ''",
|
||||
"ALTER TABLE recommendations ADD COLUMN suggested_position_pct REAL DEFAULT 0",
|
||||
"ALTER TABLE recommendations ADD COLUMN review_after_days INTEGER DEFAULT 3",
|
||||
"ALTER TABLE recommendations ADD COLUMN lifecycle_status TEXT DEFAULT 'candidate'",
|
||||
"ALTER TABLE recommendations ADD COLUMN data_freshness TEXT DEFAULT ''",
|
||||
"ALTER TABLE recommendations ADD COLUMN llm_analysis TEXT DEFAULT ''",
|
||||
"ALTER TABLE recommendations ADD COLUMN strategy TEXT DEFAULT 'momentum'",
|
||||
"ALTER TABLE recommendations ADD COLUMN llm_score REAL",
|
||||
"ALTER TABLE market_temperature ADD COLUMN max_streak INTEGER",
|
||||
"ALTER TABLE market_temperature ADD COLUMN broken_rate REAL",
|
||||
"ALTER TABLE recommendations ADD COLUMN entry_signal_type TEXT DEFAULT 'none'",
|
||||
"ALTER TABLE recommendations ADD COLUMN entry_timing TEXT DEFAULT ''",
|
||||
"ALTER TABLE sector_heat ADD COLUMN stage TEXT",
|
||||
"ALTER TABLE sector_heat ADD COLUMN days_continuous INTEGER",
|
||||
"ALTER TABLE sector_heat ADD COLUMN member_count INTEGER",
|
||||
@ -53,6 +62,27 @@ async def init_db():
|
||||
"ALTER TABLE sector_heat ADD COLUMN pct_trend TEXT",
|
||||
"ALTER TABLE sector_heat ADD COLUMN turnover_avg REAL",
|
||||
"ALTER TABLE sector_heat ADD COLUMN main_force_ratio REAL",
|
||||
"ALTER TABLE recommendation_tracking ADD COLUMN max_price REAL",
|
||||
"ALTER TABLE recommendation_tracking ADD COLUMN min_price REAL",
|
||||
"ALTER TABLE recommendation_tracking ADD COLUMN max_return_pct REAL",
|
||||
"ALTER TABLE recommendation_tracking ADD COLUMN max_drawdown_pct REAL",
|
||||
"ALTER TABLE recommendation_tracking ADD COLUMN days_since_recommendation INTEGER DEFAULT 0",
|
||||
"ALTER TABLE recommendation_tracking ADD COLUMN close_reason TEXT DEFAULT ''",
|
||||
"ALTER TABLE recommendation_tracking ADD COLUMN review_note TEXT DEFAULT ''",
|
||||
"ALTER TABLE stock_diagnoses ADD COLUMN diagnosis_mode TEXT DEFAULT 'entry'",
|
||||
"ALTER TABLE user_watchlists ADD COLUMN note TEXT DEFAULT ''",
|
||||
"ALTER TABLE user_watchlists ADD COLUMN watch_group TEXT DEFAULT 'observe'",
|
||||
"ALTER TABLE user_watchlists ADD COLUMN cost_price REAL",
|
||||
"ALTER TABLE user_watchlists ADD COLUMN is_active BOOLEAN DEFAULT 1",
|
||||
"ALTER TABLE user_watchlists ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP",
|
||||
"ALTER TABLE watchlist_analyses ADD COLUMN conclusion TEXT DEFAULT '观察'",
|
||||
"ALTER TABLE watchlist_analyses ADD COLUMN advice TEXT DEFAULT ''",
|
||||
"ALTER TABLE watchlist_analyses ADD COLUMN trigger_condition TEXT DEFAULT ''",
|
||||
"ALTER TABLE watchlist_analyses ADD COLUMN risk_note TEXT DEFAULT ''",
|
||||
"ALTER TABLE watchlist_analyses ADD COLUMN summary TEXT DEFAULT ''",
|
||||
"ALTER TABLE watchlist_analyses ADD COLUMN full_analysis TEXT DEFAULT ''",
|
||||
"ALTER TABLE watchlist_analyses ADD COLUMN score_reference REAL DEFAULT 0",
|
||||
"ALTER TABLE watchlist_analyses ADD COLUMN analysis_mode TEXT DEFAULT 'scheduled'",
|
||||
]:
|
||||
try:
|
||||
await conn.execute(
|
||||
|
||||
@ -27,9 +27,18 @@ recommendations_table = Table(
|
||||
Column("target_price", Float),
|
||||
Column("stop_loss", Float),
|
||||
Column("reasons", Text),
|
||||
Column("risk_note", Text, default=""),
|
||||
Column("action_plan", Text, default="观察"),
|
||||
Column("trigger_condition", Text, default=""),
|
||||
Column("invalidation_condition", Text, default=""),
|
||||
Column("suggested_position_pct", Float, default=0),
|
||||
Column("review_after_days", Integer, default=3),
|
||||
Column("lifecycle_status", Text, default="candidate"),
|
||||
Column("data_freshness", Text, default=""),
|
||||
Column("llm_analysis", Text, default=""),
|
||||
Column("strategy", Text, default="trend_breakout"),
|
||||
Column("entry_signal_type", Text, default="none"),
|
||||
Column("entry_timing", Text, default=""),
|
||||
Column("llm_score", Float, default=None),
|
||||
Column("scan_session", Text),
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
@ -76,8 +85,15 @@ recommendation_tracking_table = Table(
|
||||
Column("track_date", Text, nullable=False),
|
||||
Column("current_price", Float),
|
||||
Column("pct_from_entry", Float),
|
||||
Column("max_price", Float),
|
||||
Column("min_price", Float),
|
||||
Column("max_return_pct", Float),
|
||||
Column("max_drawdown_pct", Float),
|
||||
Column("days_since_recommendation", Integer, default=0),
|
||||
Column("hit_target", Boolean, default=False),
|
||||
Column("hit_stop_loss", Boolean, default=False),
|
||||
Column("close_reason", Text, default=""),
|
||||
Column("review_note", Text, default=""),
|
||||
Column("status", Text, default="active"),
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
@ -106,10 +122,43 @@ stock_diagnoses_table = Table(
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("ts_code", Text, nullable=False),
|
||||
Column("name", Text, nullable=False),
|
||||
Column("diagnosis_mode", Text, default="entry"),
|
||||
Column("diagnosis", Text, nullable=False),
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
|
||||
user_watchlists_table = Table(
|
||||
"user_watchlists", metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("user_id", Integer, nullable=False),
|
||||
Column("ts_code", Text, nullable=False),
|
||||
Column("name", Text, nullable=False),
|
||||
Column("note", Text, default=""),
|
||||
Column("watch_group", Text, default="observe"),
|
||||
Column("cost_price", Float, default=None),
|
||||
Column("is_active", Boolean, default=True),
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
Column("updated_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
|
||||
watchlist_analyses_table = Table(
|
||||
"watchlist_analyses", metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("user_id", Integer, nullable=False),
|
||||
Column("watchlist_id", Integer, nullable=False),
|
||||
Column("ts_code", Text, nullable=False),
|
||||
Column("name", Text, nullable=False),
|
||||
Column("conclusion", Text, default="观察"),
|
||||
Column("advice", Text, default=""),
|
||||
Column("trigger_condition", Text, default=""),
|
||||
Column("risk_note", Text, default=""),
|
||||
Column("summary", Text, default=""),
|
||||
Column("full_analysis", Text, default=""),
|
||||
Column("score_reference", Float, default=0),
|
||||
Column("analysis_mode", Text, default="scheduled"),
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
|
||||
error_logs_table = Table(
|
||||
"error_logs", metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -62,11 +62,12 @@ async def _update_tracking():
|
||||
# 查找所有活跃的推荐(有 entry_price 且未被标记为 closed)
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, ts_code, entry_price, target_price, stop_loss "
|
||||
"SELECT id, ts_code, entry_price, target_price, stop_loss, "
|
||||
"review_after_days, lifecycle_status, created_at "
|
||||
"FROM recommendations "
|
||||
"WHERE entry_price IS NOT NULL "
|
||||
"AND entry_price > 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"
|
||||
|
||||
@ -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("盘中调度器已配置完成")
|
||||
|
||||
|
||||
|
||||
@ -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 "强烈推荐"
|
||||
|
||||
239
backend/app/engine/watchlist.py
Normal file
239
backend/app/engine/watchlist.py
Normal file
@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -11,7 +11,7 @@ from typing import AsyncGenerator
|
||||
from app.llm.client import chat_completion, stream_chat_completion, get_client
|
||||
from app.llm.prompts import CHAT_SYSTEM_PROMPT
|
||||
from app.llm.tools import CHAT_TOOLS
|
||||
from app.llm.tool_executor import execute_tool
|
||||
from app.llm.tool_executor import execute_tool, set_chat_user_context
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -20,16 +20,18 @@ MAX_TOOL_ROUNDS = 5
|
||||
|
||||
# 工具名称映射(用于状态提示)
|
||||
TOOL_LABELS = {
|
||||
"get_strategy_board": "读取今日作战结论",
|
||||
"get_market_temperature": "查询市场温度",
|
||||
"get_hot_sectors": "查询热门板块",
|
||||
"get_latest_recommendations": "查询推荐列表",
|
||||
"get_user_watchlist_snapshot": "读取自选股作战池",
|
||||
"get_stock_kline": "查询K线数据",
|
||||
"get_stock_capital_flow": "查询资金流向",
|
||||
"search_stock": "搜索股票",
|
||||
}
|
||||
|
||||
|
||||
async def chat_stream(messages: list[dict]) -> 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)
|
||||
|
||||
@ -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}
|
||||
|
||||
## 明日关注
|
||||
优先跟踪重点关注标的能否满足触发条件,同时观察主线板块是否延续。若市场温度回落或板块资金退潮,应降低仓位并把未确认标的转回观察池。"""
|
||||
|
||||
@ -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 格式,适当用列表和加粗提升可读性
|
||||
|
||||
免责声明:你的分析仅供参考,不构成投资建议。投资有风险,入市需谨慎。
|
||||
"""
|
||||
|
||||
268
backend/app/llm/strategy_board.py
Normal file
268
backend/app/llm/strategy_board.py
Normal file
@ -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 ""
|
||||
286
backend/app/llm/strategy_iteration.py
Normal file
286
backend/app/llm/strategy_iteration.py
Normal file
@ -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)
|
||||
211
backend/app/llm/strategy_selector.py
Normal file
211
backend/app/llm/strategy_selector.py
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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)
|
||||
|
||||
Binary file not shown.
@ -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",
|
||||
|
||||
@ -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": []
|
||||
}
|
||||
@ -1 +1,20 @@
|
||||
{}
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -1 +1 @@
|
||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";
|
||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
|
||||
@ -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": []
|
||||
};
|
||||
|
||||
@ -1 +1 @@
|
||||
self.__REACT_LOADABLE_MANIFEST="{}"
|
||||
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\"]}}"
|
||||
@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"/_app": "pages/_app.js",
|
||||
"/_error": "pages/_error.js",
|
||||
"/_document": "pages/_document.js"
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "f4eykmt9lLjeIDNHjaA0ZKJupk05dXT0k2cBaExPwP8="
|
||||
"encryptionKey": "5a77t1jXySke+j0Es8vduY/7S7yObSbYfKeh0OReITs="
|
||||
}
|
||||
@ -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<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendMessage(input);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto flex flex-col h-[100dvh] pb-20 md:pb-10">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-4 sm:px-5 py-3.5 border-b border-border-subtle bg-bg-primary/80 backdrop-blur-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-accent-cyan/30 to-accent-cyan/10 flex items-center justify-center border border-accent-cyan/20">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-accent-cyan/70">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold">AI 投资顾问</h1>
|
||||
<p className="text-xs text-text-muted">
|
||||
基于实时市场数据的智能问答
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={() => setMessages([])}
|
||||
className="text-xs text-text-muted hover:text-text-primary px-3 py-1.5 rounded-lg hover:bg-surface-3 transition-all duration-200"
|
||||
>
|
||||
清空对话
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 sm:px-5 py-4 sm:py-5 space-y-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center animate-fade-in-up">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-accent-cyan/15 to-accent-cyan/5 flex items-center justify-center mb-5 border border-accent-cyan/10">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-accent-cyan/50">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<div className="mx-auto flex h-[100dvh] max-w-7xl flex-col px-4 pb-20 pt-6 md:px-8 md:pb-10">
|
||||
<div className="grid min-h-0 flex-1 gap-5 xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<aside className="hidden xl:flex xl:flex-col xl:gap-4">
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-cyan-400">
|
||||
Combat Chat
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold mb-1.5">有什么想了解的?</h2>
|
||||
<p className="text-xs text-text-muted mb-8 max-w-[240px] leading-relaxed">
|
||||
我可以查询市场数据,分析个股走势,解读板块热度
|
||||
<h1 className="mt-2 text-xl font-bold tracking-tight">AI 作战问答</h1>
|
||||
<p className="mt-2 text-sm leading-7 text-text-secondary">
|
||||
这里的价值不在聊天,而在于把今日作战结论、推荐池和你的自选股串起来,支持继续追问。
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 w-full max-w-[280px]">
|
||||
{QUICK_QUESTIONS.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => sendMessage(q)}
|
||||
className="text-xs px-4 py-2.5 bg-surface-2 rounded-xl text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all duration-200 border border-border-subtle text-left"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">
|
||||
适合拿来问
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{CHAT_SCENES.map((scene) => (
|
||||
<div key={scene.title} className="rounded-2xl border border-border-subtle bg-surface-1/70 px-4 py-3">
|
||||
<div className="text-sm font-semibold text-text-primary">{scene.title}</div>
|
||||
<div className="mt-1 text-xs leading-6 text-text-secondary">{scene.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"} animate-fade-in-up`}
|
||||
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-text-muted">
|
||||
使用原则
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-xs leading-6 text-text-secondary">
|
||||
<p>优先问系统已经有的数据和结论,不要把它当成泛财经陪聊。</p>
|
||||
<p>连续追问同一只股票或同一条主线,比重新开一个宽泛问题更有效。</p>
|
||||
<p>它会读取你的自选股上下文,所以很适合问“我该先看哪几只”。</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="glass-card-static flex min-h-0 flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3.5 sm:px-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl border border-cyan-400/15 bg-gradient-to-br from-cyan-400/15 to-cyan-400/5">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" className="text-cyan-300/80">
|
||||
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">围绕作战结果继续追问</h2>
|
||||
<p className="text-xs text-text-muted">
|
||||
今日作战 / 推荐池 / 自选股 / 主线板块
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{messages.length > 0 ? (
|
||||
<button
|
||||
onClick={() => setMessages([])}
|
||||
className="rounded-lg px-3 py-1.5 text-xs text-text-muted transition-all duration-200 hover:bg-surface-3 hover:text-text-primary"
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-2xl px-4 py-2.5 text-[13px] leading-relaxed ${
|
||||
msg.role === "user"
|
||||
? "bg-gradient-to-r from-orange-500/20 to-amber-500/15 text-orange-100 border border-orange-500/10"
|
||||
: "glass-card-static"
|
||||
}`}
|
||||
>
|
||||
{msg.role === "assistant" ? (
|
||||
msg.content ? (
|
||||
<div
|
||||
className={`prose ${theme !== "light" ? "prose-invert" : ""} prose-sm max-w-none [&_p]:my-1 [&_ul]:my-1 [&_li]:my-0.5 [&_strong]:${theme !== "light" ? "text-orange-300" : "text-orange-700"}`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formatMarkdown(msg.content),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-text-muted/50 text-xs">
|
||||
{status || "思考中..."}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span>{msg.content}</span>
|
||||
)}
|
||||
{streaming && i === messages.length - 1 && msg.role === "assistant" && msg.content && (
|
||||
<span className="inline-block w-1.5 h-4 bg-accent-cyan/60 ml-0.5 animate-pulse rounded-full" />
|
||||
)}
|
||||
清空
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full flex-col justify-center">
|
||||
<div className="mx-auto max-w-2xl text-center animate-fade-in-up">
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-[20px] border border-cyan-400/10 bg-gradient-to-br from-cyan-400/12 to-transparent">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-cyan-300/60">
|
||||
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">先用它来拆解系统已经给出的结论</h3>
|
||||
<p className="mx-auto mt-2 max-w-xl text-sm leading-7 text-text-secondary">
|
||||
这不是泛用 AI 问答框。更好的用法是直接追问今天该怎么打、推荐池为什么这样分层、你的自选股哪些该提级或降级。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-8 grid w-full max-w-3xl gap-3 md:grid-cols-2">
|
||||
{QUICK_QUESTIONS.map((question) => (
|
||||
<button
|
||||
key={question}
|
||||
onClick={() => sendMessage(question)}
|
||||
className="rounded-2xl border border-border-subtle bg-surface-1/70 px-4 py-4 text-left text-sm text-text-secondary transition-all duration-200 hover:border-cyan-400/20 hover:bg-surface-3 hover:text-text-primary"
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Status indicator during tool calls */}
|
||||
{streaming && status && messages[messages.length - 1]?.content && (
|
||||
<div className="flex justify-start">
|
||||
<div className="text-xs text-accent-cyan/50 flex items-center gap-2 px-3">
|
||||
<span className="inline-block w-2.5 h-2.5 border border-accent-cyan/30 border-t-accent-cyan/70 rounded-full animate-spin" />
|
||||
{status}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={`${message.role}-${index}`}
|
||||
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} animate-fade-in-up`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[92%] rounded-2xl px-4 py-3 text-[13px] leading-relaxed sm:max-w-[82%] ${
|
||||
message.role === "user"
|
||||
? "border border-amber-500/10 bg-gradient-to-r from-amber-500/20 to-orange-500/15 text-orange-100"
|
||||
: "border border-border-subtle bg-surface-1/80"
|
||||
}`}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
message.content ? (
|
||||
<div
|
||||
className={`prose prose-sm max-w-none ${theme !== "light" ? "prose-invert" : ""} [&_p]:my-1.5 [&_ul]:my-2 [&_li]:my-0.5`}
|
||||
dangerouslySetInnerHTML={{ __html: formatMarkdown(message.content) }}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-text-muted/60">{status || "读取作战上下文中..."}</span>
|
||||
)
|
||||
) : (
|
||||
<span>{message.content}</span>
|
||||
)}
|
||||
{streaming && index === messages.length - 1 && message.role === "assistant" && message.content ? (
|
||||
<span className="ml-1 inline-block h-4 w-1.5 animate-pulse rounded-full bg-accent-cyan/60" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{streaming && status && messages[messages.length - 1]?.content ? (
|
||||
<div className="flex justify-start">
|
||||
<div className="flex items-center gap-2 px-2 text-xs text-accent-cyan/60">
|
||||
<span className="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-accent-cyan/30 border-t-accent-cyan/80" />
|
||||
{status}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-5 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3 border-t border-border-subtle bg-bg-primary/80 backdrop-blur-xl">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入问题..."
|
||||
rows={1}
|
||||
className="flex-1 bg-surface-2 rounded-xl px-4 py-2.5 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-accent-cyan/30 placeholder-text-muted/40 border border-border-subtle transition-all duration-200"
|
||||
disabled={streaming}
|
||||
/>
|
||||
<button
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={!input.trim() || streaming}
|
||||
className="px-4 py-2.5 bg-gradient-to-r from-accent-cyan/20 to-accent-cyan/10 text-accent-cyan rounded-xl text-sm hover:from-accent-cyan/30 hover:to-accent-cyan/20 disabled:opacity-20 transition-all duration-200 shrink-0 border border-accent-cyan/10 font-medium"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-text-muted/30 text-center mt-2">
|
||||
AI 分析仅供参考,不构成投资建议
|
||||
</div>
|
||||
<div className="border-t border-border-subtle bg-bg-primary/40 px-4 py-3 sm:px-5">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="比如:结合我的自选股和今日主线,明天我应该先看哪几只?"
|
||||
rows={1}
|
||||
disabled={streaming}
|
||||
className="min-h-[46px] flex-1 resize-none rounded-2xl border border-border-subtle bg-surface-2 px-4 py-3 text-sm transition-all duration-200 placeholder:text-text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent-cyan/30"
|
||||
/>
|
||||
<button
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={!input.trim() || streaming}
|
||||
className="shrink-0 rounded-2xl border border-accent-cyan/10 bg-gradient-to-r from-accent-cyan/20 to-accent-cyan/10 px-4 py-3 text-sm font-medium text-accent-cyan transition-all duration-200 hover:from-accent-cyan/30 hover:to-accent-cyan/20 disabled:opacity-30"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 text-center text-xs text-text-muted/35">
|
||||
这里只适合围绕系统数据追问,不适合作为无上下文的泛财经问答。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -2,14 +2,12 @@
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { fetchAPI, postAPI } from "@/lib/api";
|
||||
import type { LatestResult, SectorData, IndexOverview, DailyReviewResponse } from "@/lib/api";
|
||||
import type { LatestResult, SectorData, IndexOverview, OpsStatusResponse, StrategyBoard } from "@/lib/api";
|
||||
import MarketTemp from "@/components/market-temp";
|
||||
import StockCard from "@/components/stock-card";
|
||||
import SectorHeatmap from "@/components/sector-heatmap";
|
||||
import { useWebSocket } from "@/hooks/use-websocket";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { markdownToHtml } from "@/lib/markdown";
|
||||
|
||||
interface ScanStatus {
|
||||
is_trading: boolean;
|
||||
@ -27,30 +25,28 @@ export default function DashboardPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshResult, setRefreshResult] = useState<string | null>(null);
|
||||
const [llmEnabled, setLlmEnabled] = useState(false);
|
||||
const [indices, setIndices] = useState<IndexOverview[]>([]);
|
||||
const [dailyReview, setDailyReview] = useState<string | null>(null);
|
||||
const [generatingReview, setGeneratingReview] = useState(false);
|
||||
const [strategyBoard, setStrategyBoard] = useState<StrategyBoard | null>(null);
|
||||
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
|
||||
const [opsRunning, setOpsRunning] = useState<string | null>(null);
|
||||
const scanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [latest, sectorData, status, health, overview, reviewData] = await Promise.all([
|
||||
const [latest, sectorData, status, overview, board, ops] = await Promise.all([
|
||||
fetchAPI<LatestResult>("/api/recommendations/latest"),
|
||||
fetchAPI<SectorData[]>("/api/sectors/hot?limit=8"),
|
||||
fetchAPI<ScanStatus>("/api/recommendations/status"),
|
||||
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
|
||||
fetchAPI<IndexOverview[]>("/api/market/overview").catch(() => []),
|
||||
fetchAPI<DailyReviewResponse>("/api/market/daily-review").catch(() => ({ reviews: [] })),
|
||||
fetchAPI<StrategyBoard>("/api/market/strategy-board").catch(() => null),
|
||||
fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null),
|
||||
]);
|
||||
setData(latest);
|
||||
setSectors(sectorData);
|
||||
setScanStatus(status);
|
||||
setLlmEnabled(health.llm_enabled);
|
||||
setIndices(overview);
|
||||
if (reviewData.reviews?.length > 0) {
|
||||
setDailyReview(reviewData.reviews[0].content);
|
||||
}
|
||||
setStrategyBoard(board);
|
||||
setOpsStatus(ops);
|
||||
} catch (e) {
|
||||
console.error("加载数据失败:", e);
|
||||
} finally {
|
||||
@ -124,6 +120,31 @@ export default function DashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdminAction = async (action: "update_tracking" | "generate_strategy_board" | "generate_strategy_iteration") => {
|
||||
setOpsRunning(action);
|
||||
setRefreshResult(null);
|
||||
try {
|
||||
if (action === "update_tracking") {
|
||||
const result = await postAPI<{ tracked?: number; win_rate?: number; avg_return?: number }>("/api/recommendations/update-tracking");
|
||||
setRefreshResult(`跟踪已更新,样本 ${result.tracked ?? 0},胜率 ${(result.win_rate ?? 0).toFixed(1)}%`);
|
||||
} else if (action === "generate_strategy_board") {
|
||||
await postAPI("/api/market/generate-strategy-board");
|
||||
setRefreshResult("策略板已生成");
|
||||
} else {
|
||||
await postAPI("/api/market/generate-strategy-iteration");
|
||||
setRefreshResult("策略复盘已生成");
|
||||
}
|
||||
await loadData();
|
||||
setTimeout(() => setRefreshResult(null), 5000);
|
||||
} catch (error) {
|
||||
console.error("管理员操作失败:", error);
|
||||
setRefreshResult("管理员操作失败,请重试");
|
||||
setTimeout(() => setRefreshResult(null), 5000);
|
||||
} finally {
|
||||
setOpsRunning(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 清理超时计时器
|
||||
useEffect(() => {
|
||||
return () => clearScanTimeout();
|
||||
@ -144,7 +165,7 @@ export default function DashboardPage() {
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between animate-fade-in-up">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold md:hidden tracking-tight">Dragon AI Agent</h1>
|
||||
<h1 className="text-lg font-bold tracking-tight">今日作战</h1>
|
||||
{scanStatus && (
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{scanStatus.is_trading ? (
|
||||
@ -196,102 +217,290 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Market temp + Sector heatmap */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<MarketTemp data={data?.market_temperature ?? null} indices={indices} />
|
||||
<SectorHeatmap sectors={sectors} />
|
||||
</div>
|
||||
<MissionControl
|
||||
board={strategyBoard}
|
||||
recommendations={data?.recommendations ?? []}
|
||||
sectors={sectors}
|
||||
strategyProfile={data?.strategy_profile ?? null}
|
||||
/>
|
||||
|
||||
{opsStatus && (
|
||||
user?.role === "admin" ? (
|
||||
<OpsPanel
|
||||
opsStatus={opsStatus}
|
||||
isAdmin={true}
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
opsRunning={opsRunning}
|
||||
onAction={handleAdminAction}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
|
||||
{/* Daily Review */}
|
||||
<div className="animate-fade-in-up delay-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
每日复盘
|
||||
</h2>
|
||||
{llmEnabled && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setGeneratingReview(true);
|
||||
setRefreshResult(null);
|
||||
try {
|
||||
const res = await postAPI<{ status: string; content?: string; message?: string }>("/api/market/generate-review");
|
||||
if (res.status === "ok" && res.content) {
|
||||
setDailyReview(res.content);
|
||||
} else if (res.message) {
|
||||
setRefreshResult(res.message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("生成复盘失败:", e);
|
||||
setRefreshResult("生成复盘失败,请重试");
|
||||
} finally {
|
||||
setGeneratingReview(false);
|
||||
setTimeout(() => setRefreshResult(null), 5000);
|
||||
}
|
||||
}}
|
||||
disabled={generatingReview}
|
||||
className="text-[10px] px-3 py-1.5 bg-surface-2 text-text-secondary rounded-lg hover:bg-surface-4 disabled:opacity-40 transition-all font-medium"
|
||||
>
|
||||
{generatingReview ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-2.5 h-2.5 border border-text-muted/40 border-t-text-muted rounded-full animate-spin" />
|
||||
生成中...
|
||||
</span>
|
||||
) : (
|
||||
dailyReview ? "重新生成" : "生成复盘"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<EvidenceHeader title="决策证据" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
|
||||
<MarketTemp data={data?.market_temperature ?? null} indices={indices} />
|
||||
<SectorHeatmap sectors={sectors} />
|
||||
</div>
|
||||
{dailyReview ? (
|
||||
<div className="glass-card-static p-5">
|
||||
<div
|
||||
className="text-xs text-text-secondary leading-relaxed prose prose-sm prose-invert max-w-none [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-amber-400 [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:first:mt-0 [&_p]:text-text-secondary [&_p]:mb-2 [&_ul]:text-text-secondary [&_li]:mb-1"
|
||||
dangerouslySetInnerHTML={{ __html: markdownToHtml(dailyReview) }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card-static p-6 text-center">
|
||||
<div className="text-text-muted text-sm mb-1">暂无复盘报告</div>
|
||||
<div className="text-text-muted/50 text-xs">
|
||||
{llmEnabled ? "点击「生成复盘」AI自动分析" : "配置LLM后自动生成"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
<div className="animate-fade-in-up delay-150">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
今日推荐
|
||||
{data?.recommendations?.length ? (
|
||||
<span className="text-text-primary ml-1.5 font-mono tabular-nums">{data.recommendations.length}</span>
|
||||
) : ""}
|
||||
</h2>
|
||||
<a href="/recommendations" className="text-xs text-text-muted hover:text-amber-400 transition-colors flex items-center gap-1">
|
||||
查看全部
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OpsPanel({
|
||||
opsStatus,
|
||||
isAdmin,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
opsRunning,
|
||||
onAction,
|
||||
}: {
|
||||
opsStatus: OpsStatusResponse;
|
||||
isAdmin: boolean;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
opsRunning: string | null;
|
||||
onAction: (action: "update_tracking" | "generate_strategy_board" | "generate_strategy_iteration") => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold">Admin Ops</div>
|
||||
<div className="text-xs text-text-muted mt-2">
|
||||
{opsStatus.data_freshness.message}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-text-muted">
|
||||
<span>市场 {opsStatus.data_freshness.market_trade_date || "暂无"}</span>
|
||||
<span>板块 {opsStatus.data_freshness.sector_trade_date || "暂无"}</span>
|
||||
<span>跟踪 {opsStatus.data_freshness.tracking_trade_date || "暂无"}</span>
|
||||
<span>{opsStatus.scan_running ? "扫描中" : "空闲"}</span>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing || !!opsRunning}
|
||||
className="text-xs px-3 py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-40 transition-all border border-amber-500/10"
|
||||
>
|
||||
{refreshing ? "扫描中..." : "立即扫描"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction("update_tracking")}
|
||||
disabled={refreshing || !!opsRunning}
|
||||
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{opsRunning === "update_tracking" ? "更新中..." : "更新跟踪"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction("generate_strategy_board")}
|
||||
disabled={refreshing || !!opsRunning}
|
||||
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{opsRunning === "generate_strategy_board" ? "生成中..." : "生成策略板"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction("generate_strategy_iteration")}
|
||||
disabled={refreshing || !!opsRunning}
|
||||
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{opsRunning === "generate_strategy_iteration" ? "生成中..." : "生成策略复盘"}
|
||||
</button>
|
||||
<a href="/strategy" className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors">
|
||||
查看策略复盘
|
||||
</a>
|
||||
<a href="/recommendations" className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-amber-400 transition-colors">
|
||||
查看推荐闭环
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{!data?.recommendations?.length ? (
|
||||
<div className="glass-card-static p-10 text-center">
|
||||
<div className="text-text-muted text-sm mb-1">暂无推荐</div>
|
||||
<div className="text-text-muted/60 text-xs">
|
||||
{user?.role === "admin" ? `点击 ${scanStatus?.is_trading ? "「盘中扫描」" : "「立即扫描」"} 开始分析` : "等待系统自动扫描更新"}
|
||||
function EvidenceHeader({ title }: { title: string }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MissionControl({
|
||||
board,
|
||||
recommendations,
|
||||
sectors,
|
||||
strategyProfile,
|
||||
}: {
|
||||
board: StrategyBoard | null;
|
||||
recommendations: LatestResult["recommendations"];
|
||||
sectors: SectorData[];
|
||||
strategyProfile: LatestResult["strategy_profile"];
|
||||
}) {
|
||||
const actionable = recommendations.filter((rec) => rec.action_plan === "可操作");
|
||||
const watch = recommendations.filter((rec) => rec.action_plan === "重点关注");
|
||||
const observe = recommendations.filter((rec) => !["可操作", "重点关注"].includes(rec.action_plan ?? ""));
|
||||
const topSectors = sectors.slice(0, 3);
|
||||
const risks = board?.avoid_rules?.length ? board.avoid_rules : ["等待市场状态和推荐结果更新后生成风险约束。"];
|
||||
const primaryQueue = (actionable.length ? actionable : watch).slice(0, 3);
|
||||
const laneTitle = actionable.length ? "优先执行" : watch.length ? "候选观察" : "等待信号";
|
||||
const strategyName = strategyProfile?.name ?? board?.recommended_mode ?? "待定";
|
||||
const strategyHint = strategyProfile?.description ?? "系统判断今天更适合采用的出手方式";
|
||||
const riskText = risks.slice(0, 2).join(" / ");
|
||||
|
||||
return (
|
||||
<div className="glass-card-static px-4 py-4 md:px-5 md:py-5 overflow-hidden relative animate-fade-in-up">
|
||||
<div className="absolute right-[-100px] top-[-120px] w-72 h-72 rounded-full bg-amber-500/[0.04] blur-3xl pointer-events-none" />
|
||||
<div className="relative grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_300px] gap-4">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">今日结论</span>
|
||||
{board?.generated_by === "rules+llm" && (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-cyan-500/15 bg-cyan-500/10 text-cyan-400">AI增强</span>
|
||||
)}
|
||||
</div>
|
||||
<a href="/recommendations" className="text-[11px] text-text-muted hover:text-amber-400 transition-colors">
|
||||
推荐池
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_220px] gap-3 items-start">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg md:text-[1.2rem] font-bold tracking-tight truncate">
|
||||
{board?.market_regime ?? "等待市场状态"}
|
||||
</h2>
|
||||
<p className="text-[12px] text-text-secondary leading-relaxed mt-1.5 max-w-3xl line-clamp-2">
|
||||
{board?.summary ?? "系统尚未生成今日作战结论。触发扫描后,将基于市场温度、板块主线、推荐生命周期和策略复盘生成操作框架。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 lg:grid-cols-1 gap-1.5">
|
||||
<CommandMetric label="可操作" value={actionable.length} description="盯盘" />
|
||||
<CommandMetric label="关注" value={watch.length} description="等确认" />
|
||||
<CommandMetric label="观察" value={observe.length} description="不追" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{data.recommendations.slice(0, 6).map((rec) => (
|
||||
<StockCard key={rec.ts_code} rec={rec} showLLMLoading={llmEnabled} />
|
||||
))}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-2">
|
||||
<CompactDecision label="今日打法" value={strategyName} extra={strategyHint} />
|
||||
<CompactDecision label="仓位建议" value={board?.position_suggestion ?? "等待判断"} extra="今天建议的进攻上限" />
|
||||
<CompactDecision label="市场倾向" value={board?.action_bias ?? "等待确认"} extra={`风险 ${board?.risk_level ?? "-"}`} />
|
||||
<CompactDecision label="主线板块" value={topSectors.length ? topSectors.map((sector) => sector.sector_name).join(" / ") : "暂无"} extra={riskText || "暂无风险约束"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{topSectors.length ? (
|
||||
topSectors.map((sector) => (
|
||||
<span key={sector.sector_code} className="inline-flex items-center gap-1.5 rounded-full bg-surface-1/80 border border-border-subtle px-2.5 py-1 text-[11px]">
|
||||
<span className="font-medium text-text-primary">{sector.sector_name}</span>
|
||||
<span className={`font-mono tabular-nums ${sector.pct_change >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||
{sector.pct_change > 0 ? "+" : ""}{sector.pct_change.toFixed(2)}%
|
||||
</span>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">暂无主线板块</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-surface-1/70 border border-border-subtle p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionTitle title={laneTitle} />
|
||||
<span className="text-[10px] text-text-muted">{primaryQueue.length}只</span>
|
||||
</div>
|
||||
<div className="mt-2.5 space-y-1.5">
|
||||
{primaryQueue.length ? (
|
||||
primaryQueue.map((rec) => (
|
||||
<CompactMissionStock key={`mission-${rec.ts_code}`} rec={rec} />
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-lg bg-surface-2/60 border border-border-subtle p-3 text-center">
|
||||
<div className="text-xs text-text-muted">暂无执行标的</div>
|
||||
<div className="text-[11px] text-text-muted/50 mt-0.5">等待扫描生成可操作或重点关注列表</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href="/strategy"
|
||||
className="inline-flex items-center justify-center mt-3 w-full rounded-xl border border-border-subtle bg-surface-2/60 px-3 py-2 text-[11px] text-text-muted hover:text-amber-400 hover:border-amber-500/20 transition-colors"
|
||||
>
|
||||
查看策略复盘
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactDecision({ label, value, extra }: { label: string; value: string; extra: string }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-2 px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">{label}</div>
|
||||
<div className="text-sm font-semibold text-text-primary mt-1 truncate">{value}</div>
|
||||
{extra ? <div className="text-[10px] text-text-muted mt-1 truncate">{extra}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactMissionStock({ rec }: { rec: LatestResult["recommendations"][number] }) {
|
||||
const actionStyle: Record<string, string> = {
|
||||
"可操作": "bg-red-500/15 text-red-400 border-red-500/20",
|
||||
"重点关注": "bg-amber-500/15 text-amber-400 border-amber-500/20",
|
||||
"观察": "bg-surface-3 text-text-muted border-border-default",
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/stock/${rec.ts_code}`}
|
||||
className="block rounded-lg bg-surface-2/60 border border-border-subtle px-3 py-2 hover:border-amber-500/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-sm font-semibold truncate">{rec.name}</span>
|
||||
<span className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-md border ${actionStyle[rec.action_plan ?? "观察"] ?? actionStyle["观察"]}`}>
|
||||
{rec.action_plan ?? "观察"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-text-muted font-mono tabular-nums mt-0.5 truncate">
|
||||
{rec.ts_code} · {rec.sector}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-base font-bold font-mono tabular-nums text-text-primary">{rec.score}</div>
|
||||
<div className="text-[10px] text-text-muted">参考</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[11px] text-text-secondary leading-relaxed line-clamp-1">
|
||||
{rec.trigger_condition ?? rec.reasons?.[0] ?? "等待触发条件确认"}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandMetric({ label, value, description }: { label: string; value: number; description: string }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-1/70 border border-border-subtle px-2.5 py-2">
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">{label}</div>
|
||||
<div className="flex items-baseline justify-between gap-2 mt-0.5">
|
||||
<div className="text-lg font-bold font-mono tabular-nums text-text-primary">{value}</div>
|
||||
<div className="text-[10px] text-text-muted">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { fetchAPI, type DiagnosisResult } from "@/lib/api";
|
||||
import { fetchAPI, type DiagnosisResult, type StockThesisResponse } from "@/lib/api";
|
||||
import { markdownToHtml } from "@/lib/markdown";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
|
||||
@ -13,6 +13,14 @@ interface SearchResult {
|
||||
industry: string;
|
||||
}
|
||||
|
||||
interface DiagnoseHistoryItem {
|
||||
ts_code: string;
|
||||
name: string;
|
||||
diagnosis_mode?: string;
|
||||
diagnosis?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export default function DiagnosePage() {
|
||||
const { theme } = useTheme();
|
||||
const searchParams = useSearchParams();
|
||||
@ -25,6 +33,9 @@ export default function DiagnosePage() {
|
||||
const [result, setResult] = useState<DiagnosisResult | null>(null);
|
||||
const [cachedResult, setCachedResult] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<{ ts_code: string; name: string }[]>([]);
|
||||
const [diagnoseHistory, setDiagnoseHistory] = useState<DiagnoseHistoryItem[]>([]);
|
||||
const [thesis, setThesis] = useState<StockThesisResponse | null>(null);
|
||||
const [diagnoseMode, setDiagnoseMode] = useState<"entry" | "holding" | "review" | "tracking">("entry");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
@ -92,12 +103,15 @@ export default function DiagnosePage() {
|
||||
setResult(null);
|
||||
setCachedResult(null);
|
||||
|
||||
fetchAPI<StockThesisResponse>(`/api/stocks/${code}/thesis`).then(setThesis).catch(() => setThesis(null));
|
||||
fetchAPI<DiagnoseHistoryItem[]>(`/api/stocks/${code}/diagnose/history`).then(setDiagnoseHistory).catch(() => setDiagnoseHistory([]));
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`/api/stocks/${code}/diagnose`, {
|
||||
const res = await fetch(`/api/stocks/${code}/diagnose?mode=${encodeURIComponent(diagnoseMode)}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
@ -166,7 +180,7 @@ export default function DiagnosePage() {
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="max-w-3xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||||
{/* Header */}
|
||||
<div className="mb-6 animate-fade-in-up">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
@ -178,179 +192,317 @@ export default function DiagnosePage() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold tracking-tight">AI 诊断</h1>
|
||||
<p className="text-xs text-text-muted">
|
||||
输入任意股票代码,AI 综合技术面、资金面进行全面分析
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div ref={wrapperRef} className="relative z-30 mb-6 animate-fade-in-up">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => searchResults.length > 0 && setShowSearch(true)}
|
||||
placeholder="输入股票名称或代码,如 600683 或 京投发展"
|
||||
className="w-full bg-surface-2 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-1 focus:ring-amber-400/30 placeholder-text-muted/40 border border-border-subtle transition-all duration-200"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => runDiagnosis()}
|
||||
disabled={loading || !input.trim()}
|
||||
className="px-6 py-3 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl text-sm font-medium hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-30 transition-all duration-200 border border-amber-500/10 shrink-0"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-3.5 h-3.5 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||||
分析中
|
||||
</span>
|
||||
) : (
|
||||
"开始诊断"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{showSearch && searchResults.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-bg-secondary border border-border-subtle rounded-xl shadow-lg z-20 overflow-hidden">
|
||||
{searchResults.map((stock) => (
|
||||
<button
|
||||
key={stock.ts_code}
|
||||
onClick={() => selectStock(stock)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 text-sm hover:bg-surface-3 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-primary font-medium">{stock.name}</span>
|
||||
<span className="text-text-muted text-xs">{stock.ts_code}</span>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_320px] gap-5">
|
||||
<section className="space-y-5">
|
||||
<div className="glass-card-static p-4 mb-1 animate-fade-in-up">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold mb-2">诊断工作台</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_auto] gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{[
|
||||
{ key: "entry", label: "建仓前诊断" },
|
||||
{ key: "holding", label: "持仓复核" },
|
||||
{ key: "review", label: "回撤复盘" },
|
||||
{ key: "tracking", label: "继续跟踪" },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setDiagnoseMode(item.key as typeof diagnoseMode)}
|
||||
className={`text-xs px-3 py-1.5 rounded-lg transition-all ${
|
||||
diagnoseMode === item.key
|
||||
? "bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/15"
|
||||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div ref={wrapperRef} className="relative z-30">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => searchResults.length > 0 && setShowSearch(true)}
|
||||
placeholder="输入股票名称或代码,如 600683 或 京投发展"
|
||||
className="w-full bg-surface-2 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-1 focus:ring-amber-400/30 placeholder-text-muted/40 border border-border-subtle transition-all duration-200"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => runDiagnosis()}
|
||||
disabled={loading || !input.trim()}
|
||||
className="px-6 py-3 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl text-sm font-medium hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-30 transition-all duration-200 border border-amber-500/10 shrink-0"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-3.5 h-3.5 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||||
分析中
|
||||
</span>
|
||||
) : (
|
||||
"开始诊断"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{showSearch && searchResults.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-bg-secondary border border-border-subtle rounded-xl shadow-lg z-20 overflow-hidden">
|
||||
{searchResults.map((stock) => (
|
||||
<button
|
||||
key={stock.ts_code}
|
||||
onClick={() => selectStock(stock)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 text-sm hover:bg-surface-3 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-primary font-medium">{stock.name}</span>
|
||||
<span className="text-text-muted text-xs">{stock.ts_code}</span>
|
||||
</div>
|
||||
{stock.industry && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-2 text-text-muted">
|
||||
{stock.industry}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{stock.industry && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-2 text-text-muted">
|
||||
{stock.industry}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
{history.length > 0 && !displayContent && (
|
||||
<div className="mb-6 animate-fade-in-up">
|
||||
<div className="text-[10px] text-text-muted/50 mb-2 uppercase tracking-wider">最近诊断</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{history.map((h) => (
|
||||
<button
|
||||
key={h.ts_code}
|
||||
onClick={() => {
|
||||
setInput(`${h.name} (${h.ts_code})`);
|
||||
runDiagnosis(h.ts_code);
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 bg-surface-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-all border border-border-subtle"
|
||||
>
|
||||
{h.name}
|
||||
<span className="text-text-muted/50 ml-1">{h.ts_code}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming / Loading State */}
|
||||
{loading && !displayContent && (
|
||||
<div className="glass-card-static p-10 text-center animate-fade-in-up">
|
||||
<div className="w-8 h-8 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-3" />
|
||||
<div className="text-sm text-text-secondary mb-1">正在分析中...</div>
|
||||
<div className="text-xs text-text-muted/50">收集行情数据、技术指标、资金流向并生成分析报告</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming content */}
|
||||
{loading && displayContent && (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||||
<span className="text-xs text-text-muted">AI 正在分析...</span>
|
||||
</div>
|
||||
<div className="text-sm text-text-secondary leading-relaxed whitespace-pre-line">
|
||||
{displayContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final Result */}
|
||||
{!loading && displayContent && (
|
||||
<div className="animate-fade-in-up">
|
||||
<div className="glass-card-static p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-text-primary">{result?.ts_code || codeParam}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-emerald-500/10 text-emerald-400 border border-emerald-500/15">
|
||||
{cachedResult ? "缓存" : "分析完成"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => runDiagnosis(result?.ts_code || codeParam || undefined)}
|
||||
className="text-xs text-text-muted hover:text-amber-400 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 4v6h6" />
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||
</svg>
|
||||
重新诊断
|
||||
</button>
|
||||
|
||||
{thesis ? (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">诊断上下文</div>
|
||||
<div className="text-sm text-text-secondary mt-2 leading-relaxed">
|
||||
{thesis.data_freshness.message}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 mt-3">
|
||||
{(thesis.decision_points ?? []).slice(0, 3).map((point) => (
|
||||
<DiagnosisSummaryCard key={point.label} label={point.label} value={point.value} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 text-sm text-text-muted">
|
||||
输入股票后,这里会展示推荐归档、跟踪状态和最近诊断上下文。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm text-text-secondary leading-relaxed prose prose-sm max-w-none [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-amber-400 [&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:first:mt-0 [&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-text-primary [&_h3]:mt-3 [&_h3]:mb-1.5 [&_p]:text-text-secondary [&_p]:mb-2.5 [&_p]:leading-relaxed [&_ul]:text-text-secondary [&_ul]:mb-2.5 [&_li]:mb-1 [&_strong]:text-text-primary ${theme !== "light" ? "prose-invert" : ""}`}
|
||||
dangerouslySetInnerHTML={{ __html: markdownToHtml(displayContent) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{result?.status === "error" && !loading && !displayContent && (
|
||||
<div className="glass-card-static p-8 text-center animate-fade-in-up">
|
||||
<div className="text-sm text-red-400 mb-2">诊断失败</div>
|
||||
<div className="text-xs text-text-muted">{result.message || "未知错误"}</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Streaming / Loading State */}
|
||||
{loading && !displayContent && (
|
||||
<div className="glass-card-static p-10 text-center animate-fade-in-up">
|
||||
<div className="w-8 h-8 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-3" />
|
||||
<div className="text-sm text-text-secondary mb-1">正在分析中...</div>
|
||||
<div className="text-xs text-text-muted/50">收集行情数据、技术指标、资金流向并生成分析报告</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!result && !loading && !displayContent && history.length === 0 && (
|
||||
<div className="glass-card-static p-10 text-center animate-fade-in-up">
|
||||
<div className="w-12 h-12 rounded-2xl bg-surface-2 flex items-center justify-center mx-auto mb-4">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-text-muted/40">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-sm text-text-muted mb-2">输入股票代码开始 AI 诊断</div>
|
||||
<div className="text-xs text-text-muted/50 mb-4">
|
||||
支持股票代码(如 600683)或名称(如 京投发展)
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{["贵州茅台", "宁德时代", "比亚迪"].map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => {
|
||||
setInput(name);
|
||||
searchStock(name);
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 bg-surface-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-all border border-border-subtle"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Streaming content */}
|
||||
{loading && displayContent && (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||||
<span className="text-xs text-text-muted">AI 正在分析...</span>
|
||||
</div>
|
||||
<div className="text-sm text-text-secondary leading-relaxed whitespace-pre-line">
|
||||
{displayContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final Result */}
|
||||
{!loading && displayContent && (
|
||||
<div className="animate-fade-in-up space-y-4">
|
||||
<div className="glass-card-static p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold mb-1">结构化结论</div>
|
||||
<div className="text-sm text-text-secondary">先看当前结论,再看完整长文分析。</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => runDiagnosis(result?.ts_code || codeParam || undefined)}
|
||||
className="text-xs text-text-muted hover:text-amber-400 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 4v6h6" />
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||
</svg>
|
||||
重新诊断
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-2 text-sm">
|
||||
<DiagnosisSummaryCard label="诊断模式" value={getModeLabel(diagnoseMode)} />
|
||||
<DiagnosisSummaryCard label="当前结论" value={buildDiagnosisConclusion(thesis, loading)} />
|
||||
<DiagnosisSummaryCard label="所属板块" value={thesis?.recommendation?.sector || "暂无归档"} />
|
||||
<DiagnosisSummaryCard label="风险线索" value={buildDiagnosisRisk(thesis, loading)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass-card-static p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-text-primary">{result?.ts_code || codeParam}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-emerald-500/10 text-emerald-400 border border-emerald-500/15">
|
||||
{cachedResult ? "缓存" : "分析完成"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm text-text-secondary leading-relaxed prose prose-sm max-w-none [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-amber-400 [&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:first:mt-0 [&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-text-primary [&_h3]:mt-3 [&_h3]:mb-1.5 [&_p]:text-text-secondary [&_p]:mb-2.5 [&_p]:leading-relaxed [&_ul]:text-text-secondary [&_ul]:mb-2.5 [&_li]:mb-1 [&_strong]:text-text-primary ${theme !== "light" ? "prose-invert" : ""}`}
|
||||
dangerouslySetInnerHTML={{ __html: markdownToHtml(displayContent) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{result?.status === "error" && !loading && !displayContent && (
|
||||
<div className="glass-card-static p-8 text-center animate-fade-in-up">
|
||||
<div className="text-sm text-red-400 mb-2">诊断失败</div>
|
||||
<div className="text-xs text-text-muted">{result.message || "未知错误"}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!result && !loading && !displayContent && history.length === 0 && (
|
||||
<div className="glass-card-static p-10 text-center animate-fade-in-up">
|
||||
<div className="w-12 h-12 rounded-2xl bg-surface-2 flex items-center justify-center mx-auto mb-4">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-text-muted/40">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-sm text-text-muted mb-2">输入股票代码开始 AI 诊断</div>
|
||||
<div className="text-xs text-text-muted/50 mb-4">
|
||||
支持股票代码(如 600683)或名称(如 京投发展)
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{["贵州茅台", "宁德时代", "比亚迪"].map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => {
|
||||
setInput(name);
|
||||
searchStock(name);
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 bg-surface-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-all border border-border-subtle"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="space-y-5">
|
||||
{thesis?.latest_tracking ? (
|
||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">最新跟踪</div>
|
||||
<div className="text-sm text-text-secondary mt-2 leading-relaxed">
|
||||
{thesis.latest_tracking.review_note || "暂无备注"}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{diagnoseHistory.length > 0 ? (
|
||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-text-muted font-semibold">历史会诊</div>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{diagnoseHistory.slice(0, 6).map((item, index) => (
|
||||
<span key={`${item.ts_code}-${index}`} className="text-[11px] px-2 py-1 rounded-lg bg-surface-2 border border-border-subtle text-text-secondary">
|
||||
{getModeLabel((item.diagnosis_mode as "entry" | "holding" | "review" | "tracking") || "entry")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{history.length > 0 && !displayContent ? (
|
||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-text-muted font-semibold">最近诊断</div>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{history.map((h) => (
|
||||
<button
|
||||
key={h.ts_code}
|
||||
onClick={() => {
|
||||
setInput(`${h.name} (${h.ts_code})`);
|
||||
runDiagnosis(h.ts_code);
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 bg-surface-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-all border border-border-subtle"
|
||||
>
|
||||
{h.name}
|
||||
<span className="text-text-muted/50 ml-1">{h.ts_code}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function DiagnosisSummaryCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-surface-1 border border-border-subtle px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/60">{label}</div>
|
||||
<div className="text-sm text-text-secondary mt-1 leading-relaxed">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getModeLabel(mode: "entry" | "holding" | "review" | "tracking") {
|
||||
const map = {
|
||||
entry: "建仓前诊断",
|
||||
holding: "持仓复核",
|
||||
review: "回撤复盘",
|
||||
tracking: "继续跟踪",
|
||||
};
|
||||
return map[mode];
|
||||
}
|
||||
|
||||
function getModeDescription(mode: "entry" | "holding" | "review" | "tracking") {
|
||||
const map = {
|
||||
entry: "重点看能否进场、触发条件和失效条件。",
|
||||
holding: "重点看持仓逻辑是否还成立、需不需要减仓或退出。",
|
||||
review: "重点看为何回撤、问题出在个股还是环境。",
|
||||
tracking: "重点看是否继续保留在观察池和推荐池。",
|
||||
};
|
||||
return map[mode];
|
||||
}
|
||||
|
||||
function buildDiagnosisConclusion(thesis: StockThesisResponse | null, loading: boolean) {
|
||||
if (thesis?.recommendation?.action_plan) {
|
||||
return thesis.recommendation.action_plan;
|
||||
}
|
||||
if (loading) {
|
||||
return "AI 正在生成会诊";
|
||||
}
|
||||
if (thesis?.has_recommendation === false) {
|
||||
return "暂无推荐归档,以本次诊断为准";
|
||||
}
|
||||
return "暂无明确结论";
|
||||
}
|
||||
|
||||
function buildDiagnosisRisk(thesis: StockThesisResponse | null, loading: boolean) {
|
||||
if (thesis?.recommendation?.risk_note) {
|
||||
return thesis.recommendation.risk_note;
|
||||
}
|
||||
if (thesis?.latest_tracking?.review_note) {
|
||||
return thesis.latest_tracking.review_note;
|
||||
}
|
||||
if (loading) {
|
||||
return "AI 正在补充风险判断";
|
||||
}
|
||||
return "当前没有明确风险备注";
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold tracking-tight">Dragon AI Agent</h1>
|
||||
<p className="text-xs text-text-muted mt-0.5 font-light tracking-wide">A 股智能筛选引擎</p>
|
||||
<p className="text-xs text-text-muted mt-0.5 font-light tracking-wide">A 股投研作战台</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -44,4 +44,4 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
|
||||
<MobileBottomNav />
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { fetchAPI } from "@/lib/api";
|
||||
import type { DayGroup, PerformanceStats } from "@/lib/api";
|
||||
import type { DayGroup, OpsStatusResponse, PerformanceStats, RecommendationData, StrategyIterationReport } from "@/lib/api";
|
||||
import StockCard from "@/components/stock-card";
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
@ -18,20 +18,46 @@ function formatDate(dateStr: string): string {
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${weekDays[d.getDay()]}`;
|
||||
}
|
||||
|
||||
const LIFECYCLE_LABELS: Record<string, string> = {
|
||||
candidate: "观察池",
|
||||
actionable: "可操作",
|
||||
tracking: "跟踪中",
|
||||
closed_win: "已盈利结束",
|
||||
closed_loss: "已亏损结束",
|
||||
invalidated: "已失效",
|
||||
expired: "到期未触发",
|
||||
};
|
||||
|
||||
type RecommendationWithDate = RecommendationData & { groupDate: string };
|
||||
|
||||
interface FunnelGroup {
|
||||
key: string;
|
||||
title: string;
|
||||
tone: "red" | "amber" | "cyan" | "slate";
|
||||
items: RecommendationWithDate[];
|
||||
}
|
||||
|
||||
export default function RecommendationsPage() {
|
||||
const [dayGroups, setDayGroups] = useState<DayGroup[]>([]);
|
||||
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const [activeFunnel, setActiveFunnel] = useState<string>("actionable");
|
||||
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
||||
const [iteration, setIteration] = useState<StrategyIterationReport | null>(null);
|
||||
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [history, perf] = await Promise.all([
|
||||
const [history, perf, iterationReport, ops] = await Promise.all([
|
||||
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
||||
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
||||
fetchAPI<StrategyIterationReport>("/api/market/strategy-iteration?limit=50").catch(() => null),
|
||||
fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null),
|
||||
]);
|
||||
setDayGroups(history);
|
||||
setPerformance(perf);
|
||||
setIteration(iterationReport);
|
||||
setOpsStatus(ops);
|
||||
|
||||
// 默认展开最近一天
|
||||
setExpandedDays((prev) => {
|
||||
@ -68,6 +94,51 @@ export default function RecommendationsPage() {
|
||||
return recs.filter((r) => r.level === filter);
|
||||
};
|
||||
|
||||
const allRecommendations: RecommendationWithDate[] = dayGroups.flatMap((group) => group.recommendations.map((rec) => ({
|
||||
...rec,
|
||||
groupDate: group.date,
|
||||
})));
|
||||
const latestDate = dayGroups[0]?.date ?? "";
|
||||
const latestRecommendations = latestDate
|
||||
? allRecommendations.filter((rec) => rec.groupDate === latestDate)
|
||||
: [];
|
||||
const funnelGroups: FunnelGroup[] = [
|
||||
{
|
||||
key: "actionable",
|
||||
title: "可操作",
|
||||
tone: "red",
|
||||
items: latestRecommendations.filter((r) => r.action_plan === "可操作" || r.lifecycle_status === "actionable"),
|
||||
},
|
||||
{
|
||||
key: "watch",
|
||||
title: "重点关注",
|
||||
tone: "amber",
|
||||
items: latestRecommendations.filter((r) => r.action_plan === "重点关注"),
|
||||
},
|
||||
{
|
||||
key: "tracking",
|
||||
title: "跟踪中",
|
||||
tone: "cyan",
|
||||
items: allRecommendations.filter((r) => r.lifecycle_status === "tracking").slice(0, 6),
|
||||
},
|
||||
{
|
||||
key: "closed",
|
||||
title: "已结束",
|
||||
tone: "slate",
|
||||
items: allRecommendations.filter((r) => ["closed_win", "closed_loss", "expired", "invalidated"].includes(r.lifecycle_status ?? "")).slice(0, 6),
|
||||
},
|
||||
{
|
||||
key: "observe",
|
||||
title: "观察池",
|
||||
tone: "slate",
|
||||
items: latestRecommendations.filter((r) => (r.action_plan ?? "观察") === "观察").slice(0, 6),
|
||||
},
|
||||
];
|
||||
const activeFunnelGroup =
|
||||
funnelGroups.find((group) => group.key === activeFunnel)
|
||||
?? funnelGroups.find((group) => group.items.length > 0)
|
||||
?? funnelGroups[0];
|
||||
|
||||
// 今日推荐数
|
||||
const todayCount = dayGroups.length > 0 ? applyFilter(dayGroups[0].recommendations).length : 0;
|
||||
// 累计总数
|
||||
@ -78,15 +149,52 @@ export default function RecommendationsPage() {
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5 animate-fade-in-up">
|
||||
<div>
|
||||
<h1 className="text-base sm:text-lg font-bold tracking-tight">推荐列表</h1>
|
||||
<h1 className="text-base sm:text-lg font-bold tracking-tight">推荐池</h1>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
今日 <span className="font-mono tabular-nums text-amber-400">{todayCount}</span> 只 ·
|
||||
今日入池 <span className="font-mono tabular-nums text-amber-400">{todayCount}</span> 只 ·
|
||||
<span className="font-mono tabular-nums ml-1">{totalCount}</span> 只累计 ·
|
||||
<span className="font-mono tabular-nums ml-1">{dayGroups.length}</span> 天记录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{opsStatus && (
|
||||
<div className="glass-card-static p-4 mb-5 animate-fade-in-up">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold">Data Freshness</div>
|
||||
<div className="text-sm text-text-secondary mt-2 leading-relaxed">
|
||||
{opsStatus.data_freshness.message}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 min-w-[280px]">
|
||||
<FreshnessCell label="市场日期" value={opsStatus.data_freshness.market_trade_date || "暂无"} />
|
||||
<FreshnessCell label="板块日期" value={opsStatus.data_freshness.sector_trade_date || "暂无"} />
|
||||
<FreshnessCell label="跟踪日期" value={opsStatus.data_freshness.tracking_trade_date || "暂无"} />
|
||||
<FreshnessCell label="扫描状态" value={opsStatus.scan_running ? "执行中" : "空闲"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RecommendationCommandCenter
|
||||
latestCount={latestRecommendations.length}
|
||||
actionableCount={funnelGroups[0].items.length}
|
||||
watchCount={funnelGroups[1].items.length}
|
||||
trackingCount={funnelGroups[2].items.length}
|
||||
performance={performance}
|
||||
iteration={iteration}
|
||||
/>
|
||||
|
||||
{/* Lifecycle Funnel */}
|
||||
{allRecommendations.length > 0 && (
|
||||
<FunnelWorkspace
|
||||
groups={funnelGroups}
|
||||
activeKey={activeFunnelGroup.key}
|
||||
onChange={(key) => setActiveFunnel(key)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Performance Stats */}
|
||||
{performance && performance.total_recommendations > 0 && (
|
||||
<div className="glass-card-static p-4 mb-5 animate-fade-in-up">
|
||||
@ -117,14 +225,39 @@ export default function RecommendationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-3 text-[10px] text-text-muted/60">
|
||||
<div className="flex flex-wrap items-center gap-3 mt-3 text-[10px] text-text-muted/60">
|
||||
<span>最大浮盈 {performance.avg_max_return > 0 ? "+" : ""}{performance.avg_max_return.toFixed(2)}%</span>
|
||||
<span>最大回撤 {performance.avg_max_drawdown.toFixed(2)}%</span>
|
||||
<span>命中目标 <span className="text-amber-400 font-mono tabular-nums">{performance.hit_target_count}</span></span>
|
||||
<span>触及止损 <span className="text-emerald-400 font-mono tabular-nums">{performance.hit_stop_count}</span></span>
|
||||
{Object.entries(performance.lifecycle_counts ?? {}).slice(0, 5).map(([status, count]) => (
|
||||
<span key={status}>
|
||||
{LIFECYCLE_LABELS[status] ?? status} <span className="text-text-secondary font-mono tabular-nums">{count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{iteration && iteration.sample_size > 0 && (
|
||||
<div className="glass-card-static p-4 mb-5 animate-fade-in-up">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">策略复盘迭代</h2>
|
||||
<span className="text-[10px] text-text-muted font-mono tabular-nums">样本 {iteration.sample_size}</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary leading-relaxed">{iteration.summary}</p>
|
||||
{iteration.ai_analysis && (
|
||||
<div className="mt-3 text-xs text-cyan-400/80 leading-relaxed bg-cyan-500/[0.04] border border-cyan-500/10 rounded-xl px-3 py-2">
|
||||
{iteration.ai_analysis}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="mb-2 animate-fade-in-up">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">历史记录</h2>
|
||||
</div>
|
||||
<div className="flex gap-1.5 sm:gap-2 mb-4 sm:mb-5 overflow-x-auto pb-2 -mx-4 px-4 sm:mx-0 sm:px-0 animate-fade-in-up delay-75">
|
||||
{[
|
||||
{ key: "all", label: "全部" },
|
||||
@ -201,7 +334,7 @@ export default function RecommendationsPage() {
|
||||
<div className="flex items-center gap-2 text-[10px] text-text-muted sm:hidden ml-auto shrink-0">
|
||||
<span className="font-mono tabular-nums">{filter === "all" ? group.count : filtered.length}只</span>
|
||||
<span className="text-text-muted/40">·</span>
|
||||
<span className="font-mono tabular-nums text-text-secondary">{group.avg_score}分</span>
|
||||
<span className="font-mono tabular-nums text-text-secondary">参考{group.avg_score}</span>
|
||||
{group.buy_count > 0 && (
|
||||
<>
|
||||
<span className="text-text-muted/40">·</span>
|
||||
@ -214,7 +347,7 @@ export default function RecommendationsPage() {
|
||||
{/* Desktop stats */}
|
||||
<div className="hidden sm:flex items-center gap-4 text-xs text-text-muted shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-text-muted/50">均分</span>
|
||||
<span className="text-text-muted/50">参考均值</span>
|
||||
<span className="font-mono tabular-nums font-semibold text-text-secondary">
|
||||
{group.avg_score}
|
||||
</span>
|
||||
@ -257,3 +390,210 @@ export default function RecommendationsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FunnelWorkspace({
|
||||
groups,
|
||||
activeKey,
|
||||
onChange,
|
||||
}: {
|
||||
groups: FunnelGroup[];
|
||||
activeKey: string;
|
||||
onChange: (key: string) => void;
|
||||
}) {
|
||||
const activeGroup = groups.find((group) => group.key === activeKey) ?? groups[0];
|
||||
|
||||
return (
|
||||
<div className="mb-5 animate-fade-in-up">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[280px_minmax(0,1fr)] gap-4">
|
||||
<div className="glass-card-static p-3 md:p-4">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-text-muted font-semibold mb-3">
|
||||
焦点分组
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
key={group.key}
|
||||
onClick={() => onChange(group.key)}
|
||||
className={`w-full rounded-2xl border px-3 py-3 text-left transition-all ${
|
||||
group.key === activeGroup.key
|
||||
? activeButtonTone(group.tone)
|
||||
: "border-border-subtle bg-surface-1 hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-primary">{group.title}</div>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-lg bg-surface-2 px-2 py-1 text-xs font-mono tabular-nums text-text-secondary">
|
||||
{group.items.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-4 md:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-base font-bold tracking-tight">{activeGroup.title}</h2>
|
||||
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-mono tabular-nums ${countTone(activeGroup.tone)}`}>
|
||||
{activeGroup.items.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeGroup.items.length ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[10px] uppercase tracking-wider text-text-muted font-semibold">标的列表</div>
|
||||
<div className="text-[11px] text-text-muted">内部滚动</div>
|
||||
</div>
|
||||
<div className="max-h-[980px] overflow-y-auto pr-1 md:pr-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{activeGroup.items.map((rec, index) => (
|
||||
<div
|
||||
key={`${activeGroup.key}-${rec.groupDate}-${rec.ts_code}`}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
<StockCard rec={rec} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-surface-1 border border-border-subtle p-10 text-center">
|
||||
<div className="text-sm text-text-muted">当前分组暂无标的</div>
|
||||
<div className="text-xs text-text-muted/50 mt-1">可以切换到其他分组,或等待下一次扫描结果</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendationCommandCenter({
|
||||
latestCount,
|
||||
actionableCount,
|
||||
watchCount,
|
||||
trackingCount,
|
||||
performance,
|
||||
iteration,
|
||||
}: {
|
||||
latestCount: number;
|
||||
actionableCount: number;
|
||||
watchCount: number;
|
||||
trackingCount: number;
|
||||
performance: PerformanceStats | null;
|
||||
iteration: StrategyIterationReport | null;
|
||||
}) {
|
||||
const closedCount = performance
|
||||
? (performance.lifecycle_counts?.closed_win ?? 0) + (performance.lifecycle_counts?.closed_loss ?? 0) + (performance.lifecycle_counts?.expired ?? 0)
|
||||
: 0;
|
||||
const aiInsight = iteration?.ai_analysis || iteration?.summary || "推荐进入跟踪后,系统会把命中、回撤、失效模式反馈到下一轮策略选择。";
|
||||
|
||||
return (
|
||||
<div className="glass-card-static p-5 mb-5 animate-fade-in-up overflow-hidden relative">
|
||||
<div className="absolute left-[-80px] top-[-120px] w-72 h-72 rounded-full bg-cyan-500/[0.05] blur-3xl pointer-events-none" />
|
||||
<div className="relative grid grid-cols-1 xl:grid-cols-[0.95fr_1.05fr] gap-5">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold mb-2">
|
||||
Recommendation Ops
|
||||
</div>
|
||||
<h2 className="text-xl md:text-2xl font-bold tracking-tight">推荐不是排行榜,是执行管线</h2>
|
||||
<p className="text-sm text-text-secondary leading-relaxed mt-2">
|
||||
这里把 AI 选股结果拆成四个动作池:能操作的先盯触发,未确认的继续等待,已推荐的持续跟踪,结束后的样本进入策略迭代。
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-2 mt-4">
|
||||
<PipelineMetric label="今日" value={latestCount} />
|
||||
<PipelineMetric label="可操作" value={actionableCount} tone="red" />
|
||||
<PipelineMetric label="关注" value={watchCount} tone="amber" />
|
||||
<PipelineMetric label="跟踪" value={trackingCount || (performance?.lifecycle_counts?.tracking ?? 0)} tone="cyan" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl bg-surface-1 border border-border-subtle p-4">
|
||||
<SectionTitle title="闭环质量" />
|
||||
<div className="grid grid-cols-2 gap-2 mt-3">
|
||||
<QualityCell label="胜率" value={`${(performance?.win_rate ?? 0).toFixed(1)}%`} tone={(performance?.win_rate ?? 0) >= 50 ? "red" : "emerald"} />
|
||||
<QualityCell label="平均收益" value={`${(performance?.avg_return ?? 0) > 0 ? "+" : ""}${(performance?.avg_return ?? 0).toFixed(2)}%`} tone={(performance?.avg_return ?? 0) >= 0 ? "red" : "emerald"} />
|
||||
<QualityCell label="复盘样本" value={iteration?.sample_size ?? 0} />
|
||||
<QualityCell label="已结束" value={closedCount} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-cyan-500/[0.04] border border-cyan-500/10 p-4">
|
||||
<SectionTitle title="AI 迭代提示" />
|
||||
<p className="text-xs text-cyan-400/85 leading-relaxed mt-3 line-clamp-6">
|
||||
{aiInsight}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineMetric({ label, value, tone }: { label: string; value: number; tone?: "red" | "amber" | "cyan" }) {
|
||||
const color = tone === "red" ? "text-red-400" : tone === "amber" ? "text-amber-400" : tone === "cyan" ? "text-cyan-400" : "text-text-primary";
|
||||
return (
|
||||
<div className="rounded-xl bg-surface-1 border border-border-subtle px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/60">{label}</div>
|
||||
<div className={`text-lg font-bold font-mono tabular-nums mt-0.5 ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QualityCell({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
tone?: "red" | "emerald";
|
||||
}) {
|
||||
const color = tone === "red" ? "text-red-400" : tone === "emerald" ? "text-emerald-400" : "text-text-secondary";
|
||||
return (
|
||||
<div className="rounded-xl bg-surface-2 px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/60">{label}</div>
|
||||
<div className={`text-sm font-bold font-mono tabular-nums mt-0.5 ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FreshnessCell({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-surface-1 border border-border-subtle px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/60">{label}</div>
|
||||
<div className="text-xs font-mono tabular-nums text-text-secondary mt-0.5">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function activeButtonTone(tone: FunnelGroup["tone"]): string {
|
||||
if (tone === "red") return "border-red-500/20 bg-red-500/[0.06]";
|
||||
if (tone === "amber") return "border-amber-500/20 bg-amber-500/[0.07]";
|
||||
if (tone === "cyan") return "border-cyan-500/20 bg-cyan-500/[0.07]";
|
||||
return "border-border-subtle bg-surface-2";
|
||||
}
|
||||
|
||||
function countTone(tone: FunnelGroup["tone"]): string {
|
||||
if (tone === "red") return "border-red-500/15 bg-red-500/[0.035] text-red-400";
|
||||
if (tone === "amber") return "border-amber-500/15 bg-amber-500/[0.04] text-amber-400";
|
||||
if (tone === "cyan") return "border-cyan-500/15 bg-cyan-500/[0.04] text-cyan-400";
|
||||
return "border-border-subtle bg-surface-1 text-text-secondary";
|
||||
}
|
||||
|
||||
@ -24,10 +24,10 @@ function getStageInfo(stage: string) {
|
||||
|
||||
function getOpportunityHint(stage: string, mainForceRatio?: number): string {
|
||||
switch (stage) {
|
||||
case "early": return "新行情启动,关注领涨股入场时机";
|
||||
case "mid": return (mainForceRatio ?? 0) > 30 ? "行情加速中,主力资金持续流入" : "行情发展中,关注资金动向";
|
||||
case "late": return "行情接近高位,注意获利回吐风险";
|
||||
case "end": return "行情衰退,建议谨慎观望";
|
||||
case "early": return "关注启动强度";
|
||||
case "mid": return (mainForceRatio ?? 0) > 30 ? "关注回流确认" : "观察资金动向";
|
||||
case "late": return "注意高位分化";
|
||||
case "end": return "以观望为主";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
@ -262,7 +262,7 @@ function FocusSummary({ sectors }: { sectors: SectorData[] }) {
|
||||
<h2 className="text-xs font-semibold text-amber-400 uppercase tracking-wider mb-3">
|
||||
今日关注
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-1 gap-3">
|
||||
{top3.map((sector, i) => {
|
||||
const displayPct = sector.realtime_pct_change ?? sector.pct_change;
|
||||
const isUp = displayPct > 0;
|
||||
@ -272,16 +272,18 @@ function FocusSummary({ sectors }: { sectors: SectorData[] }) {
|
||||
|
||||
return (
|
||||
<div key={sector.sector_code} className={`rounded-xl p-3 border ${stage.bg}`}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2 mb-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={`w-5 h-5 rounded-md flex items-center justify-center text-[10px] font-bold ${
|
||||
i === 0 ? "bg-amber-500/20 text-amber-400" : i === 1 ? "bg-slate-400/15 text-slate-300" : "bg-amber-700/15 text-amber-400/80"
|
||||
}`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-text-primary">{sector.sector_name}</span>
|
||||
<span className="min-w-0 break-words text-sm font-semibold text-text-primary">
|
||||
{sector.sector_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-1.5">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-md border font-medium ${stage.bg} ${stage.color}`}>
|
||||
{stage.label}
|
||||
</span>
|
||||
@ -293,7 +295,7 @@ function FocusSummary({ sectors }: { sectors: SectorData[] }) {
|
||||
<div className={`text-[11px] ${stage.color} mb-2`}>
|
||||
{hint}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px]">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[11px]">
|
||||
<span className={`font-mono tabular-nums ${sector.capital_inflow > 0 ? "text-red-400/80" : "text-emerald-400/80"}`}>
|
||||
资金{sector.capital_inflow > 0 ? "+" : ""}{formatNumber(sector.capital_inflow)}
|
||||
</span>
|
||||
@ -389,93 +391,121 @@ export default function SectorsPage() {
|
||||
return sectors.filter(s => s.stage === stageFilter);
|
||||
}, [sectors, stageFilter]);
|
||||
|
||||
const sectorBuckets = useMemo(() => {
|
||||
const mainline = sectors.slice(0, 3);
|
||||
const secondary = sectors.slice(3, 8);
|
||||
const watchlist = sectors.filter((sector) => ["late", "end"].includes(sector.stage ?? "")).slice(0, 4);
|
||||
return { mainline, secondary, watchlist };
|
||||
}, [sectors]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||||
<div className="flex items-center justify-between mb-5 animate-fade-in-up">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold tracking-tight">板块分析</h1>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
基于资金流向、涨跌表现、龙头股的热度排名
|
||||
{hasRealtime && <span className="text-emerald-400/60 ml-1">· 实时数据</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRotation(!showRotation)}
|
||||
className={`text-xs px-4 py-2 rounded-xl font-medium transition-all ${
|
||||
showRotation
|
||||
? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15"
|
||||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{showRotation ? "隐藏轮动" : "板块轮动"}
|
||||
</button>
|
||||
<div className="animate-fade-in-up mb-5">
|
||||
<h1 className="text-lg font-bold tracking-tight">板块主线</h1>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
判断当前主线、板块阶段、资金持续性和领涨股强度
|
||||
{hasRealtime && <span className="text-emerald-400/60 ml-1">· 实时数据</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sector Rotation Heatmap */}
|
||||
{showRotation && (
|
||||
<div className="mb-6 animate-fade-in-up">
|
||||
{rotationData && rotationData.sectors.length > 0 ? (
|
||||
<SectorRotationChart data={rotationData} />
|
||||
) : (
|
||||
<div className="glass-card-static p-8 text-center">
|
||||
<div className="w-6 h-6 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-2" />
|
||||
<div className="text-xs text-text-muted">加载轮动数据...</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Today's Focus - Top 3 */}
|
||||
{sectors.length > 0 && <FocusSummary sectors={sectors} />}
|
||||
|
||||
{/* Stage filter tabs */}
|
||||
{sectors.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4 animate-fade-in-up">
|
||||
{[
|
||||
{ key: "all", label: "全部", count: stageCounts.all },
|
||||
{ key: "early", label: "启动期", count: stageCounts.early },
|
||||
{ key: "mid", label: "发展期", count: stageCounts.mid },
|
||||
{ key: "late_end", label: "后期/尾声", count: stageCounts.late_end },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setStageFilter(tab.key)}
|
||||
className={`text-xs px-3 py-1.5 rounded-lg font-medium transition-all ${
|
||||
stageFilter === tab.key
|
||||
? "bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/15"
|
||||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
<span className="ml-1 text-[10px] text-text-muted/50">{tab.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sectors.length ? (
|
||||
<div className="glass-card-static p-12 text-center animate-fade-in-up">
|
||||
<div className="text-text-muted text-sm mb-1">暂无板块数据</div>
|
||||
<div className="text-text-muted/50 text-xs">触发扫描后自动更新</div>
|
||||
</div>
|
||||
) : !filteredSectors.length ? (
|
||||
<div className="glass-card-static p-12 text-center animate-fade-in-up">
|
||||
<div className="text-text-muted text-sm">当前筛选下无板块数据</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredSectors.map((sector) => {
|
||||
const originalIndex = sectors.findIndex(s => s.sector_code === sector.sector_code);
|
||||
return (
|
||||
<SectorDetailCard
|
||||
key={sector.sector_code}
|
||||
sector={sector}
|
||||
index={originalIndex}
|
||||
factorScores={factorScoresMap.get(sector.sector_code)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.35fr)_340px] gap-5">
|
||||
<section className="space-y-4">
|
||||
<MainlineCommandDeck
|
||||
mainline={sectorBuckets.mainline}
|
||||
secondary={sectorBuckets.secondary}
|
||||
watchlist={sectorBuckets.watchlist}
|
||||
/>
|
||||
|
||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-text-muted font-semibold">板块列表</div>
|
||||
<div className="text-sm text-text-secondary mt-1">先按阶段筛,再看每个板块的资金、阶段和领涨股结构。</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ key: "all", label: "全部", count: stageCounts.all },
|
||||
{ key: "early", label: "启动期", count: stageCounts.early },
|
||||
{ key: "mid", label: "发展期", count: stageCounts.mid },
|
||||
{ key: "late_end", label: "后期/尾声", count: stageCounts.late_end },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setStageFilter(tab.key)}
|
||||
className={`text-xs px-3 py-1.5 rounded-lg font-medium transition-all ${
|
||||
stageFilter === tab.key
|
||||
? "bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/15"
|
||||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
<span className="ml-1 text-[10px] text-text-muted/50">{tab.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!filteredSectors.length ? (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-8 text-center text-sm text-text-muted">
|
||||
当前筛选下无板块数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-[1200px] overflow-y-auto pr-1">
|
||||
{filteredSectors.map((sector) => {
|
||||
const originalIndex = sectors.findIndex(s => s.sector_code === sector.sector_code);
|
||||
return (
|
||||
<SectorDetailCard
|
||||
key={sector.sector_code}
|
||||
sector={sector}
|
||||
index={originalIndex}
|
||||
factorScores={factorScoresMap.get(sector.sector_code)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<FocusSummary sectors={sectors} />
|
||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold">轮动观察</div>
|
||||
<div className="text-sm text-text-secondary mt-1">轮动图收纳在侧栏,避免首屏被大图挤占。</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRotation(!showRotation)}
|
||||
className={`text-xs px-3 py-2 rounded-xl font-medium transition-all ${
|
||||
showRotation
|
||||
? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15"
|
||||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{showRotation ? "收起" : "展开"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRotation ? (
|
||||
rotationData && rotationData.sectors.length > 0 ? (
|
||||
<SectorRotationChart data={rotationData} />
|
||||
) : (
|
||||
<div className="glass-card-static p-8 text-center">
|
||||
<div className="w-6 h-6 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-2" />
|
||||
<div className="text-xs text-text-muted">加载轮动数据...</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -483,6 +513,143 @@ export default function SectorsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function MainlineCommandDeck({
|
||||
mainline,
|
||||
secondary,
|
||||
watchlist,
|
||||
}: {
|
||||
mainline: SectorData[];
|
||||
secondary: SectorData[];
|
||||
watchlist: SectorData[];
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up mb-4 overflow-hidden relative">
|
||||
<div className="absolute right-[-120px] top-[-120px] w-72 h-72 rounded-full bg-amber-500/[0.045] blur-3xl pointer-events-none" />
|
||||
<div className="relative">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold mb-2">Sector Command</div>
|
||||
<h2 className="text-xl font-bold tracking-tight">主线不是排行榜,是今天的方向分层</h2>
|
||||
<p className="text-sm text-text-secondary leading-relaxed mt-2">
|
||||
主线决定是否值得参与,次主线决定是否做轮动,观察线只保留跟踪,不抢跑。
|
||||
</p>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-3 mt-4">
|
||||
<SectorLane title="今日主线" description="可以围绕龙头和回流机会制定计划" tone="red" sectors={mainline} />
|
||||
<SectorLane title="次主线" description="可跟踪轮动,不宜当主仓位方向" tone="amber" sectors={secondary} />
|
||||
<SectorLane title="观察线" description="只看变化,不在当前阶段主动追击" tone="slate" sectors={watchlist} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectorLane({
|
||||
title,
|
||||
description,
|
||||
tone,
|
||||
sectors,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
tone: "red" | "amber" | "slate";
|
||||
sectors: SectorData[];
|
||||
}) {
|
||||
const toneClass = tone === "red"
|
||||
? "border-red-500/15 bg-red-500/[0.04] text-red-400"
|
||||
: tone === "amber"
|
||||
? "border-amber-500/15 bg-amber-500/[0.04] text-amber-400"
|
||||
: "border-border-subtle bg-surface-1 text-text-secondary";
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-surface-1/70 border border-border-subtle p-4">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold tracking-tight">{title}</h3>
|
||||
<p className="text-[11px] text-text-muted mt-0.5">{description}</p>
|
||||
</div>
|
||||
<span className={`text-xs font-mono tabular-nums rounded-lg border px-2 py-1 ${toneClass}`}>
|
||||
{sectors.length}
|
||||
</span>
|
||||
</div>
|
||||
{sectors.length ? (
|
||||
<div className="space-y-2.5 mt-3">
|
||||
{sectors.map((sector) => (
|
||||
<SectorLaneRow key={sector.sector_code} sector={sector} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-muted mt-3">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectorLaneRow({ sector }: { sector: SectorData }) {
|
||||
const stage = getStageInfo(sector.stage ?? "");
|
||||
const leaders = sector.is_realtime
|
||||
? (sector.leading_stocks_realtime?.length ? sector.leading_stocks_realtime : sector.leading_stocks)
|
||||
: sector.leading_stocks;
|
||||
const leadText = leaders?.slice(0, 2).map((stock) => stock.name).join(" / ") || "暂无代表股";
|
||||
const action = getSectorAction(sector);
|
||||
const displayPct = sector.realtime_pct_change ?? sector.pct_change;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-surface-2/70 border border-border-subtle px-3 py-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold truncate">{sector.sector_name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-md border ${stage.bg} ${stage.color}`}>
|
||||
{stage.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted mt-0.5 line-clamp-1">
|
||||
代表股:{leadText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className={`text-sm font-bold font-mono tabular-nums ${displayPct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||
{displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
|
||||
</div>
|
||||
<div className="text-[10px] text-text-muted">{action.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2 text-[11px]">
|
||||
<div className="rounded-lg bg-surface-1 px-2 py-1.5 text-text-secondary">
|
||||
参与建议:{action.advice}
|
||||
</div>
|
||||
<div className="rounded-lg bg-surface-1 px-2 py-1.5 text-text-secondary">
|
||||
失效观察:{action.risk}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSectorAction(sector: SectorData) {
|
||||
const stage = sector.stage ?? "";
|
||||
const mainForce = sector.main_force_ratio ?? 0;
|
||||
const pct = sector.realtime_pct_change ?? sector.pct_change;
|
||||
|
||||
if (stage === "early") {
|
||||
return {
|
||||
label: "可进攻",
|
||||
advice: "盯龙头和首个分歧回流,优先轻仓试错。",
|
||||
risk: "若涨停家数不扩散或龙头走弱,立刻降级观察。",
|
||||
};
|
||||
}
|
||||
if (stage === "mid" && mainForce > 20 && pct > 0) {
|
||||
return {
|
||||
label: "回流参与",
|
||||
advice: "等分歧后的承接确认,不追情绪顶点。",
|
||||
risk: "若主力占比回落或板块跟风掉队,停止追击。",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "只观察",
|
||||
advice: "只保留跟踪,不作为当前主仓位方向。",
|
||||
risk: "若主线切换或板块退潮,及时移出观察列表。",
|
||||
};
|
||||
}
|
||||
|
||||
function SectorRotationChart({ data }: { data: SectorRotationData }) {
|
||||
const [el, setEl] = useState<HTMLDivElement | null>(null);
|
||||
const { theme } = useNextTheme();
|
||||
@ -582,4 +749,4 @@ function useNextTheme() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { useTheme } = require("next-themes");
|
||||
return useTheme();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { fetchAPI } from "@/lib/api";
|
||||
import type { RecommendationData, DayGroup } from "@/lib/api";
|
||||
import { getScoreColor } from "@/lib/utils";
|
||||
import type { DayGroup, RecommendationData, StockThesisResponse } from "@/lib/api";
|
||||
import KlineChart from "@/components/kline-chart";
|
||||
import CapitalFlowChart from "@/components/capital-flow";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
@ -31,14 +30,6 @@ interface StockSignals {
|
||||
position_score: number;
|
||||
}
|
||||
|
||||
interface RecScore {
|
||||
supply_demand_score: number;
|
||||
price_action_score: number;
|
||||
technical_score: number;
|
||||
position_score: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface QuoteData {
|
||||
ts_code: string;
|
||||
name: string;
|
||||
@ -71,378 +62,398 @@ interface FlowRecord {
|
||||
sm_net: number;
|
||||
}
|
||||
|
||||
interface RecScore {
|
||||
supply_demand_score: number;
|
||||
price_action_score: number;
|
||||
technical_score: number;
|
||||
position_score: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
function isValidQuote(data: QuoteData | null): data is QuoteData {
|
||||
return !!data && typeof data.price === "number" && typeof data.pct_chg === "number";
|
||||
}
|
||||
|
||||
export default function StockDetailPage() {
|
||||
const params = useParams();
|
||||
const code = params.code as string;
|
||||
|
||||
const [thesis, setThesis] = useState<StockThesisResponse | null>(null);
|
||||
const [quote, setQuote] = useState<QuoteData | null>(null);
|
||||
const [signals, setSignals] = useState<StockSignals | null>(null);
|
||||
const [recScore, setRecScore] = useState<RecScore | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [kline, setKline] = useState<any[]>([]);
|
||||
const [kline, setKline] = useState<unknown[]>([]);
|
||||
const [capitalFlow, setCapitalFlow] = useState<FlowRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [evidenceLoading, setEvidenceLoading] = useState(false);
|
||||
const [evidenceLoaded, setEvidenceLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) return;
|
||||
Promise.all([
|
||||
fetchAPI<QuoteData>(`/api/stocks/${code}/quote`).catch(() => null),
|
||||
fetchAPI<StockSignals>(`/api/stocks/${code}/signals`).catch(() => null),
|
||||
fetchAPI<unknown[]>(`/api/stocks/${code}/kline?days=120`).catch(() => []),
|
||||
fetchAPI<FlowRecord[]>(`/api/stocks/${code}/capital_flow?days=10`).catch(() => []),
|
||||
]).then(([q, s, k, c]) => {
|
||||
setQuote(q);
|
||||
setSignals(s);
|
||||
setKline(k);
|
||||
setCapitalFlow(c as FlowRecord[]);
|
||||
});
|
||||
// 尝试从推荐历史中获取该股票的评分
|
||||
fetchAPI<DayGroup[]>(`/api/recommendations/history?days=14`).then((history) => {
|
||||
for (const group of history) {
|
||||
const rec = group.recommendations?.find((r) => r.ts_code === code);
|
||||
if (rec && rec.supply_demand_score) {
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [thesisData, history] = await Promise.all([
|
||||
fetchAPI<StockThesisResponse>(`/api/stocks/${code}/thesis`).catch(() => null),
|
||||
fetchAPI<DayGroup[]>(`/api/recommendations/history?days=14`).catch(() => []),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setThesis(thesisData);
|
||||
|
||||
const recFromHistory = history
|
||||
.flatMap((group) => group.recommendations)
|
||||
.find((rec) => rec.ts_code === code);
|
||||
const rec = thesisData?.recommendation ?? recFromHistory ?? null;
|
||||
|
||||
if (rec) {
|
||||
setRecScore({
|
||||
supply_demand_score: rec.supply_demand_score,
|
||||
supply_demand_score: rec.supply_demand_score ?? 0,
|
||||
price_action_score: rec.price_action_score ?? 0,
|
||||
technical_score: rec.technical_score,
|
||||
position_score: rec.position_score ?? 50,
|
||||
score: rec.score,
|
||||
});
|
||||
break;
|
||||
} else {
|
||||
setRecScore(null);
|
||||
}
|
||||
|
||||
setQuote(null);
|
||||
setSignals(null);
|
||||
setKline([]);
|
||||
setCapitalFlow([]);
|
||||
setEvidenceLoaded(false);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
const loadEvidence = async () => {
|
||||
if (!code || evidenceLoading || evidenceLoaded) return;
|
||||
setEvidenceLoading(true);
|
||||
try {
|
||||
const [quoteData, signalsData, klineData, flowData] = await Promise.all([
|
||||
fetchAPI<QuoteData>(`/api/stocks/${code}/quote`).catch(() => null),
|
||||
fetchAPI<StockSignals>(`/api/stocks/${code}/signals`).catch(() => null),
|
||||
fetchAPI<unknown[]>(`/api/stocks/${code}/kline?days=120`).catch(() => []),
|
||||
fetchAPI<FlowRecord[]>(`/api/stocks/${code}/capital_flow?days=10`).catch(() => []),
|
||||
]);
|
||||
setQuote(isValidQuote(quoteData) ? quoteData : null);
|
||||
setSignals(signalsData);
|
||||
setKline(Array.isArray(klineData) ? klineData : []);
|
||||
setCapitalFlow(Array.isArray(flowData) ? flowData : []);
|
||||
setEvidenceLoaded(true);
|
||||
} finally {
|
||||
setEvidenceLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const recommendation = thesis?.recommendation;
|
||||
const latestTracking = thesis?.latest_tracking;
|
||||
const latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null;
|
||||
const pageName = recommendation?.name || thesis?.name || quote?.name || code;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="max-w-6xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-4">
|
||||
{/* Back */}
|
||||
<a
|
||||
href="/"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-primary transition-colors animate-fade-in-up"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||
<a
|
||||
href="/recommendations"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-primary transition-colors animate-fade-in-up"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
返回
|
||||
</a>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
返回推荐池
|
||||
</a>
|
||||
|
||||
{/* Quote header */}
|
||||
<div className="animate-fade-in-up">
|
||||
{quote && (
|
||||
<div className="glass-card-static p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-bold tracking-tight">{quote.name}</span>
|
||||
<span className="text-sm text-text-muted font-mono tabular-nums">{quote.ts_code}</span>
|
||||
<div className="glass-card-static p-5 animate-fade-in-up overflow-hidden relative">
|
||||
<div className="absolute right-[-90px] top-[-120px] w-72 h-72 rounded-full bg-cyan-500/[0.04] blur-3xl pointer-events-none" />
|
||||
<div className="relative grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold">Stock Thesis</span>
|
||||
{recommendation?.action_plan ? (
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${getActionPlanClass(recommendation.action_plan)}`}>
|
||||
{recommendation.action_plan}
|
||||
</span>
|
||||
) : null}
|
||||
{recommendation?.lifecycle_status ? (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border-subtle bg-surface-1 text-text-muted">
|
||||
{getLifecycleLabel(recommendation.lifecycle_status)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">{pageName}</h1>
|
||||
<span className="text-sm text-text-muted font-mono tabular-nums">{code}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-text-secondary leading-relaxed mt-3 max-w-4xl">
|
||||
{buildHeroSummary(thesis, quote)}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 mt-4">
|
||||
{(thesis?.decision_points ?? []).slice(0, 3).map((point) => (
|
||||
<DecisionPoint key={point.label} label={point.label} value={point.value} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-surface-1/80 border border-border-subtle p-4">
|
||||
<SectionTitle title="数据状态" />
|
||||
<div className="text-xs text-text-secondary leading-relaxed mt-2">
|
||||
{thesis?.data_freshness.message ?? "加载中"}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-3 text-[11px] text-text-muted">
|
||||
<span>推荐 {formatDateTime(thesis?.data_freshness.recommendation_created_at)}</span>
|
||||
<span>跟踪 {thesis?.data_freshness.tracking_date || "暂无"}</span>
|
||||
<span>诊断 {thesis?.diagnoses?.length ? `${thesis.diagnoses.length}条` : "暂无"}</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/diagnose?code=${code}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-lg hover:from-amber-500/30 hover:to-amber-600/25 transition-all border border-amber-500/10"
|
||||
className="inline-flex items-center justify-center mt-3 w-full text-xs px-3 py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 transition-all border border-amber-500/10"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
AI 诊断
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span
|
||||
className={`text-3xl font-bold font-mono tabular-nums tracking-tight ${
|
||||
quote.pct_chg > 0
|
||||
? "text-red-400"
|
||||
: quote.pct_chg < 0
|
||||
? "text-emerald-400"
|
||||
: "text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{quote.price.toFixed(2)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm font-mono tabular-nums font-medium ${
|
||||
quote.pct_chg > 0 ? "text-red-400" : "text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{quote.pct_chg > 0 ? "+" : ""}
|
||||
{quote.pct_chg.toFixed(2)}%
|
||||
</span>
|
||||
{quote.pre_close && (
|
||||
<span className="text-sm text-text-muted font-mono tabular-nums">
|
||||
昨收 {quote.pre_close.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OHLC row */}
|
||||
<div className="grid grid-cols-4 gap-3 mt-4">
|
||||
<MiniStat
|
||||
label="开盘"
|
||||
value={quote.open && quote.open > 0 ? quote.open.toFixed(2) : "-"}
|
||||
color={
|
||||
quote.open && quote.open > 0 && quote.pre_close
|
||||
? quote.open >= quote.pre_close
|
||||
? "text-red-400"
|
||||
: "text-emerald-400"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<MiniStat
|
||||
label="最高"
|
||||
value={quote.high && quote.high > 0 ? quote.high.toFixed(2) : "-"}
|
||||
color="text-red-400"
|
||||
/>
|
||||
<MiniStat
|
||||
label="最低"
|
||||
value={quote.low && quote.low > 0 ? quote.low.toFixed(2) : "-"}
|
||||
color="text-emerald-400"
|
||||
/>
|
||||
<MiniStat
|
||||
label="振幅"
|
||||
value={quote.amplitude != null ? `${quote.amplitude.toFixed(2)}%` : "-"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Valuation row */}
|
||||
<div className="grid grid-cols-4 gap-3 mt-3">
|
||||
<MiniStat label="换手率" value={`${quote.turnover_rate?.toFixed(2)}%`} />
|
||||
<MiniStat label="市盈率" value={quote.pe?.toFixed(1) ?? "-"} />
|
||||
<MiniStat label="市净率" value={quote.pb?.toFixed(2) ?? "-"} />
|
||||
<MiniStat
|
||||
label="量比"
|
||||
value={quote.volume_ratio?.toFixed(2) ?? "-"}
|
||||
highlight={quote.volume_ratio != null && quote.volume_ratio > 2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Market cap row */}
|
||||
<div className="grid grid-cols-4 gap-3 mt-3">
|
||||
<MiniStat
|
||||
label="总市值"
|
||||
value={quote.total_mv ? `${formatBigNum(quote.total_mv)}亿` : "-"}
|
||||
/>
|
||||
<MiniStat
|
||||
label="流通市值"
|
||||
value={quote.circ_mv ? `${formatBigNum(quote.circ_mv)}亿` : "-"}
|
||||
/>
|
||||
<MiniStat
|
||||
label="涨停"
|
||||
value={quote.limit_up?.toFixed(2) ?? "-"}
|
||||
color="text-red-400"
|
||||
/>
|
||||
<MiniStat
|
||||
label="跌停"
|
||||
value={quote.limit_down?.toFixed(2) ?? "-"}
|
||||
color="text-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Position Safety + Capital Flow Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in-up delay-75">
|
||||
{/* Position Safety Card */}
|
||||
{signals && (
|
||||
<div className="glass-card-static p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="w-1 h-4 rounded-full" style={{ backgroundColor: getPositionColor(signals.position_score) }} />
|
||||
<h2 className="text-sm font-bold tracking-tight">
|
||||
仓位安全评估
|
||||
</h2>
|
||||
<span className={`text-lg font-bold font-mono tabular-nums ml-auto`} style={{ color: getPositionColor(signals.position_score) }}>
|
||||
{Math.round(signals.position_score)}
|
||||
<span className="text-[10px] text-text-muted ml-0.5">安全分</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* 位置安全评分条 */}
|
||||
<div className="mb-4">
|
||||
<div className="h-2.5 bg-surface-3 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-700 ease-out"
|
||||
style={{ width: `${Math.min(signals.position_score, 100)}%`, backgroundColor: getPositionColor(signals.position_score) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<PositionBar label="5日涨幅" value={signals.rally_pct_5d} />
|
||||
<PositionBar label="10日涨幅" value={signals.rally_pct_10d} />
|
||||
<PositionBar label="距高点" value={signals.distance_from_high} invert />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Capital Flow Breakdown */}
|
||||
{latestFlow && (
|
||||
<CapitalFlowBreakdown flow={latestFlow} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Technical signals */}
|
||||
{signals && (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up delay-75">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-sm font-bold tracking-tight">
|
||||
技术面分析
|
||||
</h2>
|
||||
{recScore && (
|
||||
<div className={`text-2xl font-bold font-mono tabular-nums ${getScoreColor(recScore.score)}`}>
|
||||
{recScore.score}
|
||||
<span className="text-xs text-text-muted ml-1">综合分</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Module 1: 核心评分维度 ── */}
|
||||
<div className="mb-5 pb-5 border-b border-border-subtle">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-1 h-4 rounded-full bg-amber-500/70" />
|
||||
<span className="text-xs font-semibold text-text-secondary">核心评分维度</span>
|
||||
{recScore && (
|
||||
<span className="text-[10px] text-text-muted/40 ml-1">综合 = 供需×50% + 形态×40% + 趋势×10%</span>
|
||||
)}
|
||||
</div>
|
||||
{recScore ? (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<DimensionScore label="供需关系" sublabel="50%权重" value={recScore.supply_demand_score} />
|
||||
<DimensionScore label="价格形态" sublabel="40%权重" value={recScore.price_action_score} />
|
||||
<DimensionScore label="趋势方向" sublabel="10%权重" value={recScore.technical_score} />
|
||||
<DimensionScore label="位置安全" sublabel="防追高" value={recScore.position_score} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DimensionScore label="趋势评分" sublabel="均线排列+结构+MA20方向" value={signals.trend_score} />
|
||||
<DimensionScore label="信号计数" sublabel="触发即加分,仅供参考" value={(signals.signal_count / 7) * 100} displayValue={`${signals.signal_count}/7`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Module 2: 辅助信号 ── */}
|
||||
<div className="mb-5 pb-5 border-b border-border-subtle">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-1 h-4 rounded-full bg-cyan-500/70" />
|
||||
<span className="text-xs font-semibold text-text-secondary">辅助信号</span>
|
||||
<span className="text-[10px] text-text-muted/30">·触发即加分,不参与主评分</span>
|
||||
<span className={`text-xs font-mono tabular-nums ml-auto ${signals.signal_count >= 4 ? "text-amber-400" : signals.signal_count >= 2 ? "text-cyan-400" : "text-text-muted"}`}>
|
||||
{signals.signal_count}/7
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<SignalChip label="均线多头" active={signals.ma_bullish} points={15} />
|
||||
<SignalChip label="放量突破" active={signals.volume_breakout} points={20} />
|
||||
<SignalChip label="MACD金叉" active={signals.macd_golden} points={15} />
|
||||
<SignalChip label="RSI健康" active={signals.rsi_healthy} points={10} />
|
||||
<SignalChip label="缩量回踩" active={signals.pullback_support} points={15} />
|
||||
<SignalChip label="放量长阳" active={signals.big_yang} points={15} />
|
||||
<SignalChip label="布林支撑" active={signals.boll_support} points={10} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Module 3: 关键价位 ── */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-1 h-4 rounded-full bg-emerald-500/70" />
|
||||
<span className="text-xs font-semibold text-text-secondary">关键价位</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<PriceLevel label="支撑位" value={signals.support_price} color="text-orange-400" />
|
||||
<PriceLevel label="压力位" value={signals.resist_price} color="text-red-400" />
|
||||
<PriceLevel label="止损位" value={signals.stop_loss_price} color="text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* K-line + Capital flow chart */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in-up delay-150">
|
||||
{kline.length > 0 && <KlineChart data={kline} />}
|
||||
{capitalFlow.length > 0 && <CapitalFlowChart data={capitalFlow} />}
|
||||
{loading ? (
|
||||
<div className="glass-card-static p-8 text-center text-sm text-text-muted">加载个股推演中...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4 animate-fade-in-up">
|
||||
<div className="space-y-4">
|
||||
<PlanCard recommendation={recommendation} trackingNote={latestTracking?.review_note || ""} />
|
||||
<EvidenceCard recommendation={recommendation} />
|
||||
<DiagnosisArchiveCard diagnoses={thesis?.diagnoses ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<TrackingCard tracking={latestTracking ?? null} />
|
||||
<QuoteSnapshot quote={quote} evidenceLoaded={evidenceLoaded} />
|
||||
<SignalSnapshot signals={signals} recScore={recScore} evidenceLoaded={evidenceLoaded} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<SectionTitle title="行情证据" />
|
||||
<button
|
||||
onClick={loadEvidence}
|
||||
disabled={evidenceLoading || evidenceLoaded}
|
||||
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{evidenceLoading ? "加载中..." : evidenceLoaded ? "已加载证据" : "加载行情证据"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{evidenceLoaded ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in-up">
|
||||
{kline.length > 0 ? <KlineChart data={kline as never[]} /> : <ChartEmptyCard title="K线图" description="暂无K线缓存数据" />}
|
||||
{capitalFlow.length > 0 ? <CapitalFlowChart data={capitalFlow} /> : <ChartEmptyCard title="资金流向趋势" description="暂无资金流缓存数据" />}
|
||||
</div>
|
||||
|
||||
{latestFlow ? (
|
||||
<CapitalFlowBreakdown flow={latestFlow} />
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Helper components ── */
|
||||
|
||||
function MiniStat({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
highlight,
|
||||
function PlanCard({
|
||||
recommendation,
|
||||
trackingNote,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
highlight?: boolean;
|
||||
recommendation: RecommendationData | null | undefined;
|
||||
trackingNote: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-xl px-3 py-2.5 border ${highlight ? "border-amber-500/20 bg-amber-500/[0.04]" : "border-border-subtle bg-surface-1"}`}>
|
||||
<div className="text-[11px] text-text-muted leading-tight">{label}</div>
|
||||
<div className={`text-sm font-bold font-mono tabular-nums mt-0.5 ${color ?? ""}`}>
|
||||
{value}
|
||||
<div className="glass-card-static p-5">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<SectionTitle title="操作计划" />
|
||||
{recommendation?.llm_score != null ? (
|
||||
<span className="text-xs font-mono tabular-nums text-cyan-400/80">AI {recommendation.llm_score}/10</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-3 text-sm">
|
||||
<PlanRow label="触发条件" value={recommendation?.trigger_condition || "暂无明确触发条件"} />
|
||||
<PlanRow label="失效条件" value={recommendation?.invalidation_condition || "暂无明确失效条件"} />
|
||||
<PlanRow label="建议仓位" value={recommendation?.suggested_position_pct != null ? `${recommendation.suggested_position_pct}%` : "未设置"} />
|
||||
<PlanRow label="复盘周期" value={recommendation?.review_after_days ? `${recommendation.review_after_days}个交易日` : "未设置"} />
|
||||
{trackingNote ? <PlanRow label="跟踪结论" value={trackingNote} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PositionBar({ label, value, invert = false }: { label: string; value: number; invert?: boolean }) {
|
||||
const absVal = Math.abs(value);
|
||||
const maxDisplay = 30;
|
||||
const pct = Math.min(absVal / maxDisplay, 1) * 100;
|
||||
const isPositive = value > 0;
|
||||
const showWarning = invert ? value < -20 : value > 20;
|
||||
const barColor = showWarning ? "bg-amber-400" : isPositive ? "bg-red-400" : "bg-emerald-400";
|
||||
const textColor = showWarning ? "text-amber-400" : isPositive ? "text-red-400" : "text-emerald-400";
|
||||
|
||||
function EvidenceCard({ recommendation }: { recommendation: RecommendationData | null | undefined }) {
|
||||
const reasons = recommendation?.reasons ?? [];
|
||||
return (
|
||||
<div className="bg-surface-1 rounded-xl px-3 py-2.5 border border-border-subtle">
|
||||
<div className="text-[10px] text-text-muted leading-tight">{label}</div>
|
||||
<div className={`text-sm font-bold font-mono tabular-nums ${textColor}`}>
|
||||
{isPositive ? "+" : ""}
|
||||
{value.toFixed(1)}%
|
||||
<div className="glass-card-static p-5">
|
||||
<SectionTitle title="推荐依据" />
|
||||
<div className="space-y-2 mt-3">
|
||||
{reasons.length ? reasons.map((reason, index) => (
|
||||
<div key={index} className="text-sm text-text-secondary leading-relaxed flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400/70 mt-[8px] shrink-0" />
|
||||
<span>{reason}</span>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-sm text-text-muted">暂无结构化推荐依据</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-surface-3 overflow-hidden mt-1.5">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
{recommendation?.risk_note ? (
|
||||
<div className="mt-4 rounded-xl bg-amber-500/[0.04] border border-amber-500/10 px-3 py-2 text-xs text-amber-400/80 leading-relaxed">
|
||||
风险提示:{recommendation.risk_note}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagnosisArchiveCard({ diagnoses }: { diagnoses: StockThesisResponse["diagnoses"] }) {
|
||||
return (
|
||||
<div className="glass-card-static p-5">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<SectionTitle title="AI 推演归档" />
|
||||
<span className="text-[10px] text-text-muted font-mono tabular-nums">{diagnoses.length}条</span>
|
||||
</div>
|
||||
{diagnoses.length ? (
|
||||
<div className="space-y-3">
|
||||
{diagnoses.map((item) => (
|
||||
<div key={item.id} className="rounded-xl bg-surface-1 border border-border-subtle p-3">
|
||||
<div className="text-[10px] text-text-muted mb-2">{formatDateTime(item.created_at)}</div>
|
||||
<div className="text-xs text-text-secondary whitespace-pre-line leading-relaxed line-clamp-6">
|
||||
{item.diagnosis}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-text-muted">暂无 AI 诊断归档</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackingCard({ tracking }: { tracking: StockThesisResponse["latest_tracking"] }) {
|
||||
return (
|
||||
<div className="glass-card-static p-5">
|
||||
<SectionTitle title="结果跟踪" />
|
||||
{tracking ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 mt-3">
|
||||
<TrackingMetric label="当前收益" value={tracking.pct_from_entry} />
|
||||
<TrackingMetric label="最大浮盈" value={tracking.max_return_pct} />
|
||||
<TrackingMetric label="最大回撤" value={tracking.max_drawdown_pct} />
|
||||
<MiniDataCell label="跟踪天数" value={tracking.days_since_recommendation ?? 0} />
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-text-secondary leading-relaxed">
|
||||
{tracking.review_note || "暂无跟踪备注"}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-text-muted mt-3">暂无跟踪记录</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuoteSnapshot({ quote, evidenceLoaded }: { quote: QuoteData | null; evidenceLoaded: boolean }) {
|
||||
return (
|
||||
<div className="glass-card-static p-5">
|
||||
<SectionTitle title="行情快照" />
|
||||
{quote ? (
|
||||
<>
|
||||
<div className="flex items-baseline gap-3 mt-3">
|
||||
<span className={`text-3xl font-bold font-mono tabular-nums ${quote.pct_chg > 0 ? "text-red-400" : quote.pct_chg < 0 ? "text-emerald-400" : "text-text-primary"}`}>
|
||||
{quote.price.toFixed(2)}
|
||||
</span>
|
||||
<span className={`text-sm font-mono tabular-nums ${quote.pct_chg > 0 ? "text-red-400" : quote.pct_chg < 0 ? "text-emerald-400" : "text-text-muted"}`}>
|
||||
{quote.pct_chg > 0 ? "+" : ""}{quote.pct_chg.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-3">
|
||||
<MiniDataCell label="换手率" value={quote.turnover_rate != null ? `${quote.turnover_rate.toFixed(2)}%` : "-"} />
|
||||
<MiniDataCell label="量比" value={quote.volume_ratio != null ? quote.volume_ratio.toFixed(2) : "-"} />
|
||||
<MiniDataCell label="总市值" value={quote.total_mv != null ? `${formatBigNum(quote.total_mv)}亿` : "-"} />
|
||||
<MiniDataCell label="振幅" value={quote.amplitude != null ? `${quote.amplitude.toFixed(2)}%` : "-"} />
|
||||
</div>
|
||||
</>
|
||||
) : evidenceLoaded ? (
|
||||
<div className="text-sm text-text-muted mt-3">暂未获取到实时行情</div>
|
||||
) : (
|
||||
<div className="text-sm text-text-muted mt-3">点击“加载行情证据”后查看</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignalSnapshot({
|
||||
signals,
|
||||
recScore,
|
||||
evidenceLoaded,
|
||||
}: {
|
||||
signals: StockSignals | null;
|
||||
recScore: RecScore | null;
|
||||
evidenceLoaded: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-card-static p-5">
|
||||
<SectionTitle title="证据维度" />
|
||||
{signals ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 mt-3">
|
||||
<DimensionScore label="供需" value={recScore?.supply_demand_score ?? signals.trend_score} />
|
||||
<DimensionScore label="形态" value={recScore?.price_action_score ?? signals.signal_count * 12} />
|
||||
<DimensionScore label="趋势" value={recScore?.technical_score ?? signals.trend_score} />
|
||||
<DimensionScore label="位置" value={recScore?.position_score ?? signals.position_score} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-3">
|
||||
<SignalFlag label="均线多头" active={signals.ma_bullish} />
|
||||
<SignalFlag label="放量突破" active={signals.volume_breakout} />
|
||||
<SignalFlag label="RSI健康" active={signals.rsi_healthy} />
|
||||
</div>
|
||||
</>
|
||||
) : evidenceLoaded ? (
|
||||
<div className="text-sm text-text-muted mt-3">暂无技术面快照</div>
|
||||
) : (
|
||||
<div className="text-sm text-text-muted mt-3">点击“加载行情证据”后查看</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapitalFlowBreakdown({ flow }: { flow: FlowRecord }) {
|
||||
const maxDisplay = Math.max(
|
||||
...[flow.elg_net, flow.lg_net, flow.md_net, flow.sm_net].map(Math.abs),
|
||||
1
|
||||
);
|
||||
const maxDisplay = Math.max(...[flow.elg_net, flow.lg_net, flow.md_net, flow.sm_net].map((value) => Math.abs(value)), 1);
|
||||
const isMainInflow = flow.main_net_inflow > 0;
|
||||
|
||||
return (
|
||||
<div className="glass-card-static p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className={`w-1 h-4 rounded-full ${isMainInflow ? "bg-red-500/70" : "bg-emerald-500/70"}`} />
|
||||
<h2 className="text-sm font-bold tracking-tight">
|
||||
今日资金流向
|
||||
</h2>
|
||||
<span
|
||||
className={`text-lg font-bold font-mono tabular-nums ml-auto ${isMainInflow ? "text-red-400" : "text-emerald-400"}`}
|
||||
>
|
||||
{isMainInflow ? "+" : ""}
|
||||
{formatFlowAmount(flow.main_net_inflow)}
|
||||
<span className="text-[10px] text-text-muted ml-0.5">主力净流入</span>
|
||||
<h2 className="text-sm font-bold tracking-tight">资金流拆解</h2>
|
||||
<span className={`text-lg font-bold font-mono tabular-nums ml-auto ${isMainInflow ? "text-red-400" : "text-emerald-400"}`}>
|
||||
{isMainInflow ? "+" : ""}{formatFlowAmount(flow.main_net_inflow)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
@ -455,6 +466,79 @@ function CapitalFlowBreakdown({ flow }: { flow: FlowRecord }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ChartEmptyCard({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="bg-bg-card rounded-xl p-4">
|
||||
<h2 className="text-sm font-medium text-text-secondary mb-2">{title}</h2>
|
||||
<div className="h-56 flex items-center justify-center text-sm text-text-muted">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DecisionPoint({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-surface-1/70 border border-border-subtle px-3 py-2.5">
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">{label}</div>
|
||||
<div className="text-sm text-text-primary leading-relaxed mt-1">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[84px_1fr] gap-3">
|
||||
<div className="text-xs text-text-muted">{label}</div>
|
||||
<div className="text-sm text-text-secondary leading-relaxed">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniDataCell({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-surface-2 px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/60">{label}</div>
|
||||
<div className="text-sm font-mono tabular-nums text-text-secondary mt-0.5">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackingMetric({ label, value }: { label: string; value: number | null | undefined }) {
|
||||
const num = value ?? 0;
|
||||
const color = num > 0 ? "text-red-400" : num < 0 ? "text-emerald-400" : "text-text-secondary";
|
||||
return (
|
||||
<div className="rounded-xl bg-surface-2 px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/60">{label}</div>
|
||||
<div className={`text-sm font-bold font-mono tabular-nums mt-0.5 ${color}`}>
|
||||
{num > 0 ? "+" : ""}{num.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DimensionScore({ label, value }: { label: string; value: number }) {
|
||||
const width = Math.max(0, Math.min(value, 100));
|
||||
const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-text-secondary">{label}</span>
|
||||
<span className="text-xs font-mono tabular-nums text-text-muted">{value.toFixed(0)}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${gradientClass}`} style={{ width: `${width}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignalFlag({ label, active }: { label: string; active: boolean }) {
|
||||
return (
|
||||
<div className={`rounded-xl border px-3 py-2 text-xs ${active ? "border-red-500/15 bg-red-500/[0.05] text-red-400" : "border-border-subtle bg-surface-2 text-text-muted"}`}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowBar({ label, value, max }: { label: string; value: number; max: number }) {
|
||||
const absVal = Math.abs(value);
|
||||
const pct = (absVal / max) * 100;
|
||||
@ -464,86 +548,52 @@ function FlowBar({ label, value, max }: { label: string; value: number; max: num
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-text-muted">{label}</span>
|
||||
<span
|
||||
className={`font-mono tabular-nums ${isInflow ? "text-red-400" : "text-emerald-400"}`}
|
||||
>
|
||||
{isInflow ? "+" : ""}
|
||||
{formatFlowAmount(value)}
|
||||
<span className={`font-mono tabular-nums ${isInflow ? "text-red-400" : "text-emerald-400"}`}>
|
||||
{isInflow ? "+" : ""}{formatFlowAmount(value)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-surface-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
isInflow ? "bg-red-400" : "bg-emerald-400"
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
<div className={`h-full rounded-full ${isInflow ? "bg-red-400" : "bg-emerald-400"}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignalChip({ label, active, points }: { label: string; active: boolean; points: number }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center py-2.5 rounded-xl transition-all duration-200 ${
|
||||
active
|
||||
? "bg-red-500/[0.08] border border-red-500/15"
|
||||
: "bg-surface-1 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<span className={`text-[11px] font-medium ${active ? "text-red-400" : "text-text-muted/60"}`}>
|
||||
{label}
|
||||
</span>
|
||||
<span className={`text-[11px] font-mono tabular-nums mt-0.5 ${active ? "text-amber-400 font-semibold" : "text-text-muted/30"}`}>
|
||||
{active ? `+${points}` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
function SectionTitle({ title }: { title: string }) {
|
||||
return <div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">{title}</div>;
|
||||
}
|
||||
|
||||
function DimensionScore({ label, sublabel, value, displayValue }: { label: string; sublabel: string; value: number; displayValue?: string }) {
|
||||
const width = Math.min(value, 100);
|
||||
const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low";
|
||||
const scoreColor = value >= 70 ? "text-amber-400" : value >= 50 ? "text-cyan-400" : "text-text-muted";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<div>
|
||||
<span className={`text-xs font-semibold ${scoreColor}`}>{label}</span>
|
||||
<span className="text-[10px] text-text-muted/40 ml-1">{sublabel}</span>
|
||||
</div>
|
||||
<span className={`text-sm font-bold font-mono tabular-nums ${scoreColor}`}>
|
||||
{displayValue ?? value.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-700 ease-out ${gradientClass}`}
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function getLifecycleLabel(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
candidate: "观察池",
|
||||
actionable: "可操作",
|
||||
tracking: "跟踪中",
|
||||
closed_win: "盈利结束",
|
||||
closed_loss: "亏损结束",
|
||||
expired: "到期复盘",
|
||||
invalidated: "已失效",
|
||||
};
|
||||
return labels[status] ?? status;
|
||||
}
|
||||
|
||||
function PriceLevel({ label, value, color }: { label: string; value: number | null; color: string }) {
|
||||
return (
|
||||
<div className="bg-surface-1 rounded-xl px-3 py-2.5 border border-border-subtle">
|
||||
<div className="text-[10px] text-text-muted leading-tight">{label}</div>
|
||||
<div className={`text-sm font-bold font-mono tabular-nums ${color}`}>
|
||||
{value?.toFixed(2) ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function getActionPlanClass(actionPlan: string) {
|
||||
if (actionPlan === "可操作") return "border-red-500/20 bg-red-500/10 text-red-400";
|
||||
if (actionPlan === "重点关注") return "border-amber-500/20 bg-amber-500/10 text-amber-400";
|
||||
return "border-border-subtle bg-surface-1 text-text-muted";
|
||||
}
|
||||
|
||||
/* ── Helper functions ── */
|
||||
function buildHeroSummary(thesis: StockThesisResponse | null, quote: QuoteData | null) {
|
||||
if (thesis?.has_recommendation && thesis.recommendation) {
|
||||
const rec = thesis.recommendation;
|
||||
const quoteText = quote ? `当前价格 ${quote.price.toFixed(2)},涨跌幅 ${quote.pct_chg > 0 ? "+" : ""}${quote.pct_chg.toFixed(2)}%。` : "";
|
||||
return `当前处于「${rec.action_plan ?? "观察"}」阶段。${quoteText} 先看触发与失效条件,再看技术和资金证据,不把参考分当成最终结论。`;
|
||||
}
|
||||
return "暂无推荐归档,这里优先展示该股票的历史诊断与行情证据。后续若进入推荐池,会在此沉淀操作计划和跟踪结果。";
|
||||
}
|
||||
|
||||
function getPositionColor(score: number) {
|
||||
if (score >= 70) return "#22c55e";
|
||||
if (score >= 40) return "#eab308";
|
||||
return "#ef4444";
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) return "暂无";
|
||||
return value.replace("T", " ").slice(0, 16);
|
||||
}
|
||||
|
||||
function formatBigNum(val: number): string {
|
||||
|
||||
196
frontend/src/app/(auth)/strategy/page.tsx
Normal file
196
frontend/src/app/(auth)/strategy/page.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { fetchAPI } from "@/lib/api";
|
||||
import type { PerformanceStats, StrategyIterationReport, StrategyStat, StrategyAdjustment } from "@/lib/api";
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
tighten: "收紧",
|
||||
promote: "加强",
|
||||
reduce: "降权",
|
||||
keep: "保持",
|
||||
observe: "观察",
|
||||
};
|
||||
|
||||
export default function StrategyPage() {
|
||||
const [iteration, setIteration] = useState<StrategyIterationReport | null>(null);
|
||||
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [iterationReport, perf] = await Promise.all([
|
||||
fetchAPI<StrategyIterationReport>("/api/market/strategy-iteration?limit=80").catch(() => null),
|
||||
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
||||
]);
|
||||
setIteration(iterationReport);
|
||||
setPerformance(perf);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 space-y-4">
|
||||
<div className="h-32 glass-card-static animate-shimmer" />
|
||||
<div className="h-64 glass-card-static animate-shimmer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||
<div className="animate-fade-in-up">
|
||||
<h1 className="text-xl font-bold tracking-tight">策略复盘</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 animate-fade-in-up">
|
||||
<MetricCard label="复盘样本" value={iteration?.sample_size ?? 0} />
|
||||
<MetricCard label="整体胜率" value={`${(performance?.win_rate ?? 0).toFixed(1)}%`} tone={(performance?.win_rate ?? 0) >= 50 ? "up" : "down"} />
|
||||
<MetricCard label="平均收益" value={`${(performance?.avg_return ?? 0) > 0 ? "+" : ""}${(performance?.avg_return ?? 0).toFixed(2)}%`} tone={(performance?.avg_return ?? 0) >= 0 ? "up" : "down"} />
|
||||
<MetricCard label="平均回撤" value={`${(performance?.avg_max_drawdown ?? 0).toFixed(2)}%`} tone="down" />
|
||||
</div>
|
||||
|
||||
{iteration ? (
|
||||
<>
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||
<div>
|
||||
<SectionTitle title="复盘摘要" />
|
||||
<p className="text-sm text-text-secondary leading-relaxed mt-2 max-w-3xl">
|
||||
{iteration.summary}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-[10px] text-text-muted font-mono tabular-nums">
|
||||
{new Date(iteration.generated_at).toLocaleString("zh-CN")}
|
||||
</div>
|
||||
</div>
|
||||
{iteration.ai_analysis && (
|
||||
<div className="mt-4 rounded-xl bg-cyan-500/[0.04] border border-cyan-500/10 p-4 text-sm text-cyan-400/85 leading-relaxed">
|
||||
{iteration.ai_analysis}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<SectionTitle title="下一轮策略指令" />
|
||||
<div className="space-y-3 mt-3">
|
||||
{(iteration.adjustment_suggestions.length ? iteration.adjustment_suggestions : [
|
||||
{ target: "推荐系统", action: "observe", reason: "等待更多跟踪样本后再调整策略权重。", confidence: "低" },
|
||||
]).slice(0, 5).map((item, index) => (
|
||||
<NextInstruction key={`${item.target}-${index}`} item={item} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<StatsPanel title="按策略表现" stats={iteration.strategy_stats} />
|
||||
<StatsPanel title="按信号表现" stats={iteration.signal_stats} />
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<SectionTitle title="失败模式" />
|
||||
<div className="space-y-2 mt-3">
|
||||
{iteration.failure_patterns.map((pattern, index) => (
|
||||
<div key={index} className="rounded-xl bg-surface-1 border border-border-subtle px-3 py-2.5 text-xs text-text-secondary leading-relaxed">
|
||||
{pattern}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="glass-card-static p-10 text-center">
|
||||
<div className="text-text-muted text-sm">暂无策略复盘数据</div>
|
||||
<div className="text-text-muted/50 text-xs mt-1">等待推荐跟踪产生后自动生成</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextInstruction({ item, index }: { item: StrategyAdjustment; index: number }) {
|
||||
const verb = ACTION_LABELS[item.action] ?? item.action;
|
||||
const color = item.action === "promote"
|
||||
? "text-red-400 bg-red-500/[0.04] border-red-500/15"
|
||||
: item.action === "tighten" || item.action === "reduce"
|
||||
? "text-amber-400 bg-amber-500/[0.04] border-amber-500/15"
|
||||
: "text-cyan-400 bg-cyan-500/[0.04] border-cyan-500/15";
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-3 ${color}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider opacity-80 mb-1">
|
||||
指令 {index + 1} · {verb}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-text-primary">{item.target}</div>
|
||||
</div>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-2 border border-border-subtle text-text-muted shrink-0">
|
||||
置信 {item.confidence}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs leading-relaxed text-text-secondary mt-2">{item.reason}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, tone }: { label: string; value: string | number; tone?: "up" | "down" }) {
|
||||
const color = tone === "up" ? "text-red-400" : tone === "down" ? "text-emerald-400" : "text-text-primary";
|
||||
return (
|
||||
<div className="glass-card-static p-4">
|
||||
<div className="text-[10px] text-text-muted/60 mb-1">{label}</div>
|
||||
<div className={`text-xl font-bold font-mono tabular-nums ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsPanel({ title, stats }: { title: string; stats: StrategyStat[] }) {
|
||||
return (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<SectionTitle title={title} />
|
||||
<div className="space-y-2 mt-3">
|
||||
{stats.length ? stats.slice(0, 6).map((stat) => (
|
||||
<div key={stat.name} className="rounded-xl bg-surface-1 border border-border-subtle p-3">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="text-sm font-semibold text-text-primary truncate">{stat.name}</div>
|
||||
<div className={`text-sm font-bold font-mono tabular-nums ${stat.avg_return > 0 ? "text-red-400" : stat.avg_return < 0 ? "text-emerald-400" : "text-text-secondary"}`}>
|
||||
{stat.avg_return > 0 ? "+" : ""}{stat.avg_return.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 text-[10px] text-text-muted">
|
||||
<StatCell label="样本" value={stat.count} />
|
||||
<StatCell label="胜率" value={`${stat.win_rate.toFixed(1)}%`} />
|
||||
<StatCell label="浮盈" value={`${stat.avg_max_return > 0 ? "+" : ""}${stat.avg_max_return.toFixed(1)}%`} />
|
||||
<StatCell label="回撤" value={`${stat.avg_max_drawdown.toFixed(1)}%`} />
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-sm text-text-muted">暂无分组数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCell({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-2 px-2 py-1.5">
|
||||
<div className="text-[9px] text-text-muted/50">{label}</div>
|
||||
<div className="text-[11px] text-text-secondary font-mono tabular-nums mt-0.5">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
666
frontend/src/app/(auth)/watchlists/page.tsx
Normal file
666
frontend/src/app/(auth)/watchlists/page.tsx
Normal file
@ -0,0 +1,666 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
deleteAPI,
|
||||
fetchAPI,
|
||||
patchAPI,
|
||||
postAPI,
|
||||
type WatchlistHistoryItem,
|
||||
type WatchlistItem,
|
||||
} from "@/lib/api";
|
||||
|
||||
interface SearchResult {
|
||||
ts_code: string;
|
||||
name: string;
|
||||
industry: string;
|
||||
}
|
||||
|
||||
type WatchGroup = "observe" | "focus" | "candidate" | "holding";
|
||||
|
||||
const GROUP_META: Record<WatchGroup, { label: string; description: string }> = {
|
||||
observe: { label: "观察池", description: "有线索,但暂时不进入重点节奏" },
|
||||
focus: { label: "重点池", description: "明日优先跟踪" },
|
||||
candidate: { label: "候选池", description: "接近执行方案" },
|
||||
holding: { label: "持仓池", description: "看兑现和风控" },
|
||||
};
|
||||
|
||||
export default function WatchlistsPage() {
|
||||
const [items, setItems] = useState<WatchlistItem[]>([]);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [selected, setSelected] = useState<SearchResult | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [watchGroup, setWatchGroup] = useState<WatchGroup>("observe");
|
||||
const [costPrice, setCostPrice] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [runningId, setRunningId] = useState<number | null>(null);
|
||||
const [history, setHistory] = useState<Record<number, WatchlistHistoryItem[]>>({});
|
||||
const [error, setError] = useState("");
|
||||
const [activeGroup, setActiveGroup] = useState<WatchGroup>("focus");
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editingNote, setEditingNote] = useState("");
|
||||
const [editingCost, setEditingCost] = useState("");
|
||||
|
||||
const loadWatchlists = useCallback(async () => {
|
||||
const data = await fetchAPI<WatchlistItem[]>("/api/watchlists");
|
||||
setItems(data);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadWatchlists().catch((err: Error) => setError(err.message || "自选股加载失败"));
|
||||
}, [loadWatchlists]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (!keyword.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
const data = await fetchAPI<SearchResult[]>(`/api/stocks/search?keyword=${encodeURIComponent(keyword)}`).catch(() => []);
|
||||
setResults(data);
|
||||
}, 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [keyword]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const seed: Record<WatchGroup, WatchlistItem[]> = {
|
||||
observe: [],
|
||||
focus: [],
|
||||
candidate: [],
|
||||
holding: [],
|
||||
};
|
||||
for (const item of items) {
|
||||
const key = (item.watch_group || "observe") as WatchGroup;
|
||||
seed[key].push(item);
|
||||
}
|
||||
return seed;
|
||||
}, [items]);
|
||||
|
||||
const tomorrowActions = useMemo(
|
||||
() => items.filter((item) => item.conclusion === "可操作" || item.conclusion === "重点关注").slice(0, 6),
|
||||
[items]
|
||||
);
|
||||
const holdingItems = grouped.holding;
|
||||
const activeItems = grouped[activeGroup];
|
||||
|
||||
const addWatchlist = async () => {
|
||||
if (!selected) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await postAPI("/api/watchlists", {
|
||||
ts_code: selected.ts_code,
|
||||
name: selected.name,
|
||||
note,
|
||||
watch_group: watchGroup,
|
||||
cost_price: toPositiveNumber(costPrice),
|
||||
});
|
||||
setKeyword("");
|
||||
setResults([]);
|
||||
setSelected(null);
|
||||
setNote("");
|
||||
setWatchGroup("observe");
|
||||
setCostPrice("");
|
||||
await loadWatchlists();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加入自选失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeWatchlist = async (id: number) => {
|
||||
setError("");
|
||||
try {
|
||||
await deleteAPI(`/api/watchlists/${id}`);
|
||||
setHistory((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
if (editingId === id) {
|
||||
setEditingId(null);
|
||||
}
|
||||
await loadWatchlists();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "删除自选失败");
|
||||
}
|
||||
};
|
||||
|
||||
const runAnalysis = async (id: number) => {
|
||||
setRunningId(id);
|
||||
setError("");
|
||||
try {
|
||||
await postAPI(`/api/watchlists/${id}/analyze`);
|
||||
await loadWatchlists();
|
||||
const data = await fetchAPI<WatchlistHistoryItem[]>(`/api/watchlists/${id}/history`).catch(() => []);
|
||||
setHistory((prev) => ({ ...prev, [id]: data }));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "分析失败");
|
||||
} finally {
|
||||
setRunningId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runBatchAnalysis = async () => {
|
||||
setRunningId(-1);
|
||||
setError("");
|
||||
try {
|
||||
await postAPI("/api/watchlists/analyze-all");
|
||||
await loadWatchlists();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "批量分析失败");
|
||||
} finally {
|
||||
setRunningId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadHistory = async (id: number) => {
|
||||
if (history[id]) return;
|
||||
const data = await fetchAPI<WatchlistHistoryItem[]>(`/api/watchlists/${id}/history`).catch(() => []);
|
||||
setHistory((prev) => ({ ...prev, [id]: data }));
|
||||
};
|
||||
|
||||
const moveGroup = async (item: WatchlistItem, nextGroup: WatchGroup) => {
|
||||
if ((item.watch_group || "observe") === nextGroup) return;
|
||||
setError("");
|
||||
try {
|
||||
await patchAPI(`/api/watchlists/${item.id}`, { watch_group: nextGroup });
|
||||
await loadWatchlists();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "更新分组失败");
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (item: WatchlistItem) => {
|
||||
setEditingId(item.id);
|
||||
setEditingNote(item.note || "");
|
||||
setEditingCost(item.cost_price ? String(item.cost_price) : "");
|
||||
};
|
||||
|
||||
const saveEdit = async (item: WatchlistItem) => {
|
||||
setError("");
|
||||
try {
|
||||
await patchAPI(`/api/watchlists/${item.id}`, {
|
||||
note: editingNote,
|
||||
cost_price: toPositiveNumber(editingCost),
|
||||
});
|
||||
setEditingId(null);
|
||||
await loadWatchlists();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "更新失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||
<div className="animate-fade-in-up">
|
||||
<h1 className="text-lg font-bold tracking-tight">自选股作战池</h1>
|
||||
</div>
|
||||
|
||||
<WatchlistOverview
|
||||
items={items}
|
||||
grouped={grouped}
|
||||
tomorrowActions={tomorrowActions}
|
||||
onBatchAnalyze={runBatchAnalysis}
|
||||
runningId={runningId}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.55fr)_minmax(320px,0.9fr)] gap-5">
|
||||
<section className="space-y-5">
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold mb-3">新增自选</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_180px_160px_auto] gap-3">
|
||||
<div>
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value);
|
||||
setSelected(null);
|
||||
}}
|
||||
placeholder="输入股票名称或代码"
|
||||
className="w-full bg-surface-2 rounded-xl px-4 py-3 text-sm border border-border-subtle focus:outline-none focus:ring-1 focus:ring-amber-400/30"
|
||||
/>
|
||||
{results.length > 0 && !selected ? (
|
||||
<div className="mt-2 rounded-xl border border-border-subtle bg-bg-secondary overflow-hidden">
|
||||
{results.slice(0, 8).map((item) => (
|
||||
<button
|
||||
key={item.ts_code}
|
||||
onClick={() => {
|
||||
setSelected(item);
|
||||
setKeyword(`${item.name} (${item.ts_code})`);
|
||||
setResults([]);
|
||||
}}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm text-text-primary">{item.name}</span>
|
||||
<span className="block text-[11px] text-text-muted mt-0.5">{item.industry || "未分类"}</span>
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">{item.ts_code}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<select
|
||||
value={watchGroup}
|
||||
onChange={(e) => setWatchGroup(e.target.value as WatchGroup)}
|
||||
className="w-full bg-surface-2 rounded-xl px-4 py-3 text-sm border border-border-subtle focus:outline-none focus:ring-1 focus:ring-amber-400/30"
|
||||
>
|
||||
{Object.entries(GROUP_META).map(([key, meta]) => (
|
||||
<option key={key} value={key}>
|
||||
{meta.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={costPrice}
|
||||
onChange={(e) => setCostPrice(e.target.value)}
|
||||
placeholder="持仓成本"
|
||||
className="w-full bg-surface-2 rounded-xl px-4 py-3 text-sm border border-border-subtle focus:outline-none focus:ring-1 focus:ring-amber-400/30"
|
||||
/>
|
||||
<button
|
||||
onClick={addWatchlist}
|
||||
disabled={!selected || loading}
|
||||
className="px-4 py-3 rounded-xl bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 text-sm disabled:opacity-40"
|
||||
>
|
||||
{loading ? "添加中..." : "加入作战池"}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="备注:为什么关注它,或者你现在的交易计划"
|
||||
className="mt-3 w-full bg-surface-2 rounded-xl px-4 py-3 text-sm border border-border-subtle focus:outline-none focus:ring-1 focus:ring-amber-400/30"
|
||||
/>
|
||||
{error ? (
|
||||
<div className="mt-3 rounded-xl border border-red-500/15 bg-red-500/8 px-3 py-2 text-xs text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">Watchlist Workspace</div>
|
||||
<div className="text-sm text-text-secondary mt-1">左手管理分组,右手只看当前焦点池,避免整页无限拉长。</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["focus", "candidate", "holding", "observe"] as WatchGroup[]).map((groupKey) => (
|
||||
<button
|
||||
key={groupKey}
|
||||
onClick={() => setActiveGroup(groupKey)}
|
||||
className={`rounded-xl border px-3 py-2 text-xs transition-colors ${
|
||||
activeGroup === groupKey
|
||||
? "border-amber-500/20 bg-amber-500/10 text-amber-400"
|
||||
: "border-border-subtle bg-surface-1 text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{GROUP_META[groupKey].label}
|
||||
<span className="ml-2 font-mono tabular-nums">{grouped[groupKey].length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeItems.length === 0 ? (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-8 text-center text-sm text-text-muted">
|
||||
当前分组还没有股票
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[1080px] overflow-y-auto pr-1">
|
||||
{activeItems.map((item) => (
|
||||
<WatchlistCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
history={history[item.id]}
|
||||
editing={editingId === item.id}
|
||||
editingNote={editingNote}
|
||||
editingCost={editingCost}
|
||||
running={runningId === item.id}
|
||||
onEdit={() => startEdit(item)}
|
||||
onCancelEdit={() => setEditingId(null)}
|
||||
onChangeNote={setEditingNote}
|
||||
onChangeCost={setEditingCost}
|
||||
onSave={() => saveEdit(item)}
|
||||
onAnalyze={() => runAnalysis(item.id)}
|
||||
onDelete={() => removeWatchlist(item.id)}
|
||||
onLoadHistory={() => loadHistory(item.id)}
|
||||
onMoveGroup={(group) => moveGroup(item, group)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="space-y-5">
|
||||
<TomorrowPanel items={tomorrowActions} />
|
||||
<HoldingPanel items={holdingItems} />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WatchlistOverview({
|
||||
items,
|
||||
grouped,
|
||||
tomorrowActions,
|
||||
onBatchAnalyze,
|
||||
runningId,
|
||||
}: {
|
||||
items: WatchlistItem[];
|
||||
grouped: Record<WatchGroup, WatchlistItem[]>;
|
||||
tomorrowActions: WatchlistItem[];
|
||||
onBatchAnalyze: () => void;
|
||||
runningId: number | null;
|
||||
}) {
|
||||
const actionable = items.filter((item) => item.conclusion === "可操作").length;
|
||||
const focus = items.filter((item) => item.conclusion === "重点关注").length;
|
||||
|
||||
return (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up overflow-hidden relative">
|
||||
<div className="absolute right-[-60px] top-[-80px] w-72 h-72 rounded-full bg-cyan-500/[0.05] blur-3xl pointer-events-none" />
|
||||
<div className="relative grid grid-cols-1 xl:grid-cols-[1.1fr_0.9fr] gap-5">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold">Personal Mission Control</div>
|
||||
<h2 className="text-xl font-bold tracking-tight mt-2">把自选股变成你的个人执行池</h2>
|
||||
<p className="text-sm text-text-secondary leading-relaxed mt-2">
|
||||
观察池负责收集线索,重点池负责明日盯盘,候选池负责临门一脚,持仓池负责兑现和风控。
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 mt-4">
|
||||
<OverviewMetric label="总数" value={items.length} />
|
||||
<OverviewMetric label="可操作" value={actionable} tone="emerald" />
|
||||
<OverviewMetric label="重点关注" value={focus} tone="amber" />
|
||||
<OverviewMetric label="持仓池" value={grouped.holding.length} tone="cyan" />
|
||||
<OverviewMetric label="明日行动" value={tomorrowActions.length} tone="rose" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-text-muted font-semibold">统一动作</div>
|
||||
<div className="text-sm text-text-secondary mt-1">你可以手动刷新整池分析,也可以等系统收盘后自动跑。</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onBatchAnalyze}
|
||||
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-2 text-text-secondary hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
{runningId === -1 ? "分析中..." : "刷新整池"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-4">
|
||||
{(["observe", "focus", "candidate", "holding"] as WatchGroup[]).map((group) => (
|
||||
<div key={group} className="rounded-xl bg-surface-2 px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/60">{GROUP_META[group].label}</div>
|
||||
<div className="text-sm font-bold font-mono tabular-nums mt-0.5 text-text-primary">{grouped[group].length}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WatchlistCard({
|
||||
item,
|
||||
history,
|
||||
editing,
|
||||
editingNote,
|
||||
editingCost,
|
||||
running,
|
||||
onEdit,
|
||||
onCancelEdit,
|
||||
onChangeNote,
|
||||
onChangeCost,
|
||||
onSave,
|
||||
onAnalyze,
|
||||
onDelete,
|
||||
onLoadHistory,
|
||||
onMoveGroup,
|
||||
}: {
|
||||
item: WatchlistItem;
|
||||
history?: WatchlistHistoryItem[];
|
||||
editing: boolean;
|
||||
editingNote: string;
|
||||
editingCost: string;
|
||||
running: boolean;
|
||||
onEdit: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onChangeNote: (value: string) => void;
|
||||
onChangeCost: (value: string) => void;
|
||||
onSave: () => void;
|
||||
onAnalyze: () => void;
|
||||
onDelete: () => void;
|
||||
onLoadHistory: () => void;
|
||||
onMoveGroup: (group: WatchGroup) => void;
|
||||
}) {
|
||||
const currentGroup = (item.watch_group || "observe") as WatchGroup;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-text-primary">{item.name}</span>
|
||||
<span className="text-xs text-text-muted font-mono">{item.ts_code}</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-cyan-500/15 bg-cyan-500/10 text-cyan-300">
|
||||
{GROUP_META[currentGroup].label}
|
||||
</span>
|
||||
{item.conclusion ? <span className={badgeClass(item.conclusion)}>{item.conclusion}</span> : null}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1">
|
||||
{item.cost_price ? `持仓成本 ${item.cost_price}` : "未填写持仓成本"} · 最近分析 {item.analysis_created_at ? formatDateTime(item.analysis_created_at) : "暂无"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap justify-end">
|
||||
<button onClick={onAnalyze} className="text-xs px-3 py-1.5 rounded-lg border border-border-subtle bg-surface-2 text-text-secondary hover:text-cyan-400 transition-colors">
|
||||
{running ? "分析中..." : "重新分析"}
|
||||
</button>
|
||||
<button onClick={editing ? onCancelEdit : onEdit} className="text-xs px-3 py-1.5 rounded-lg border border-border-subtle bg-surface-2 text-text-secondary hover:text-amber-400 transition-colors">
|
||||
{editing ? "取消" : "编辑"}
|
||||
</button>
|
||||
<button onClick={onDelete} className="text-xs px-3 py-1.5 rounded-lg border border-border-subtle bg-surface-2 text-text-muted hover:text-red-400 transition-colors">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 mt-3">
|
||||
<WatchlistMetric label="一句话建议" value={item.summary || item.advice || "暂无分析"} />
|
||||
<WatchlistMetric label="触发条件" value={item.trigger_condition || "等待更多确认"} />
|
||||
<WatchlistMetric label="风险提示" value={item.risk_note || "暂无"} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="text-[10px] uppercase tracking-wider text-text-muted font-semibold mb-2">快速分组</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["observe", "focus", "candidate", "holding"] as WatchGroup[]).map((group) => (
|
||||
<button
|
||||
key={group}
|
||||
onClick={() => onMoveGroup(group)}
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs transition-colors ${
|
||||
currentGroup === group
|
||||
? "border-amber-500/20 bg-amber-500/10 text-amber-400"
|
||||
: "border-border-subtle bg-surface-2 text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{GROUP_META[group].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="mt-3 rounded-2xl border border-border-subtle bg-surface-2/70 p-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-[minmax(0,1fr)_160px_auto] gap-3">
|
||||
<input
|
||||
value={editingNote}
|
||||
onChange={(e) => onChangeNote(e.target.value)}
|
||||
placeholder="更新你的观察计划"
|
||||
className="w-full bg-surface-1 rounded-xl px-4 py-3 text-sm border border-border-subtle focus:outline-none focus:ring-1 focus:ring-amber-400/30"
|
||||
/>
|
||||
<input
|
||||
value={editingCost}
|
||||
onChange={(e) => onChangeCost(e.target.value)}
|
||||
placeholder="持仓成本"
|
||||
className="w-full bg-surface-1 rounded-xl px-4 py-3 text-sm border border-border-subtle focus:outline-none focus:ring-1 focus:ring-amber-400/30"
|
||||
/>
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="px-4 py-3 rounded-xl bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 text-sm"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 rounded-xl bg-surface-2/70 px-3 py-2 text-sm text-text-secondary">
|
||||
{item.note || "你还没有写这只股票的个人计划。"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-3 text-[11px]">
|
||||
<button onClick={onLoadHistory} className="text-cyan-400/80 hover:text-cyan-400 transition-colors">
|
||||
查看历史分析
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{history?.length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{history.slice(0, 3).map((row) => (
|
||||
<div key={row.id} className="rounded-xl bg-surface-2/70 border border-border-subtle px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted mb-1 flex items-center gap-2 flex-wrap">
|
||||
<span>{formatDateTime(row.created_at)}</span>
|
||||
<span>·</span>
|
||||
<span>{row.analysis_mode === "scheduled" ? "定时分析" : "手动分析"}</span>
|
||||
<span className={badgeClass(row.conclusion)}>{row.conclusion}</span>
|
||||
</div>
|
||||
<div className="text-sm text-text-secondary">{row.summary || row.advice}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TomorrowPanel({ items }: { items: WatchlistItem[] }) {
|
||||
return (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-rose-300 font-semibold">明日行动清单</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-xl border border-border-subtle bg-surface-1/70 p-4 text-sm text-text-muted">
|
||||
目前还没有需要放入明日优先节奏的股票。
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-border-subtle bg-surface-1/70 p-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-text-primary">{item.name}</span>
|
||||
<span className="text-xs font-mono text-text-muted">{item.ts_code}</span>
|
||||
<span className={badgeClass(item.conclusion || "观察")}>{item.conclusion}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-relaxed text-text-secondary">{item.trigger_condition || item.summary || item.advice || "等待更多确认"}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HoldingPanel({ items }: { items: WatchlistItem[] }) {
|
||||
return (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-300 font-semibold">持仓池</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-xl border border-border-subtle bg-surface-1/70 p-4 text-sm text-text-muted">
|
||||
你还没有把任何股票放进持仓池。
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-border-subtle bg-surface-1/70 p-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-text-primary">{item.name}</span>
|
||||
<span className="text-xs font-mono text-text-muted">{item.ts_code}</span>
|
||||
{item.cost_price ? (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border-subtle bg-surface-2 text-text-secondary">
|
||||
成本 {item.cost_price}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-relaxed text-text-secondary">{item.risk_note || item.summary || "继续跟踪兑现节奏与风险控制。"}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewMetric({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: "emerald" | "amber" | "cyan" | "rose";
|
||||
}) {
|
||||
const color =
|
||||
tone === "emerald"
|
||||
? "text-emerald-300"
|
||||
: tone === "amber"
|
||||
? "text-amber-400"
|
||||
: tone === "cyan"
|
||||
? "text-cyan-300"
|
||||
: tone === "rose"
|
||||
? "text-rose-300"
|
||||
: "text-text-primary";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-surface-1 border border-border-subtle px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/60">{label}</div>
|
||||
<div className={`text-lg font-bold font-mono tabular-nums mt-0.5 ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WatchlistMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-surface-2 px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/60">{label}</div>
|
||||
<div className="text-sm text-text-secondary mt-1 leading-relaxed">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function badgeClass(conclusion: string): string {
|
||||
if (conclusion === "可操作") {
|
||||
return "text-[10px] px-2 py-0.5 rounded-full border border-emerald-500/15 bg-emerald-500/10 text-emerald-300";
|
||||
}
|
||||
if (conclusion === "重点关注") {
|
||||
return "text-[10px] px-2 py-0.5 rounded-full border border-amber-500/15 bg-amber-500/10 text-amber-400";
|
||||
}
|
||||
if (conclusion === "回避") {
|
||||
return "text-[10px] px-2 py-0.5 rounded-full border border-rose-500/15 bg-rose-500/10 text-rose-300";
|
||||
}
|
||||
return "text-[10px] px-2 py-0.5 rounded-full border border-border-subtle bg-surface-2 text-text-muted";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return value.slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
function toPositiveNumber(value: string): number | null {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) && num > 0 ? num : null;
|
||||
}
|
||||
@ -1,344 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/* ── Animated data stream background ── */
|
||||
function DataStreamCanvas() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const stockCodes = [
|
||||
"600519.SH", "000858.SZ", "601318.SH", "000001.SZ",
|
||||
"600036.SH", "000333.SZ", "601166.SH", "002415.SZ",
|
||||
"600276.SH", "000568.SZ", "601888.SH", "300750.SZ",
|
||||
"600900.SH", "000725.SZ", "603259.SH", "002230.SZ",
|
||||
];
|
||||
|
||||
const streams: { x: number; y: number; speed: number; code: string; pct: string; opacity: number }[] = [];
|
||||
const streamCount = 18;
|
||||
|
||||
for (let i = 0; i < streamCount; i++) {
|
||||
streams.push({
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
speed: 0.15 + Math.random() * 0.3,
|
||||
code: stockCodes[Math.floor(Math.random() * stockCodes.length)],
|
||||
pct: `${(Math.random() * 6 - 1).toFixed(2)}%`,
|
||||
opacity: 0.08 + Math.random() * 0.12,
|
||||
});
|
||||
}
|
||||
|
||||
let animId: number;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
function resize() {
|
||||
if (!canvas || !ctx) return;
|
||||
canvas.width = canvas.offsetWidth * dpr;
|
||||
canvas.height = canvas.offsetHeight * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
}
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
const displayWidth = () => canvas.offsetWidth;
|
||||
const displayHeight = () => canvas.offsetHeight;
|
||||
const fontSize = () => Math.max(10, displayWidth() * 0.012);
|
||||
|
||||
function draw() {
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
streams.forEach((s) => {
|
||||
s.y += s.speed;
|
||||
if (s.y > 105) {
|
||||
s.y = -5;
|
||||
s.x = Math.random() * 100;
|
||||
s.code = stockCodes[Math.floor(Math.random() * stockCodes.length)];
|
||||
s.pct = `${(Math.random() * 6 - 1).toFixed(2)}%`;
|
||||
s.opacity = 0.08 + Math.random() * 0.12;
|
||||
}
|
||||
|
||||
const px = (s.x / 100) * displayWidth();
|
||||
const py = (s.y / 100) * displayHeight();
|
||||
|
||||
ctx.fillStyle = `rgba(251, 191, 36, ${s.opacity})`;
|
||||
ctx.font = `${fontSize()}px monospace`;
|
||||
ctx.fillText(s.code, px, py);
|
||||
|
||||
const isUp = parseFloat(s.pct) > 0;
|
||||
ctx.fillStyle = isUp
|
||||
? `rgba(255, 107, 107, ${s.opacity * 0.9})`
|
||||
: `rgba(52, 211, 153, ${s.opacity * 0.9})`;
|
||||
ctx.fillText(s.pct, px, py + fontSize() * 1.3);
|
||||
});
|
||||
|
||||
animId = requestAnimationFrame(draw);
|
||||
}
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ opacity: 0.8 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Feature icons (inline SVG) ── */
|
||||
function ScanIcon() {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
<path d="M3 12h1m16 0h1M12 3v1m0 16v1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HeatmapIcon() {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="6" height="6" rx="1" />
|
||||
<rect x="15" y="3" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" />
|
||||
<rect x="3" y="15" width="6" height="6" rx="1" />
|
||||
<rect x="15" y="15" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterIcon() {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 4h18l-7 8v6l-4 2v-8L3 4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BrainIcon() {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2a5 5 0 0 1 5 5c0 2-1 3.5-2.5 4.5L12 13l-2.5-1.5C8 10.5 7 9 7 7a5 5 0 0 1 5-5z" />
|
||||
<path d="M12 13v4" />
|
||||
<path d="M8 17h8" />
|
||||
<path d="M8 21h8" />
|
||||
<circle cx="9" cy="7" r="0.5" fill="currentColor" />
|
||||
<circle cx="15" cy="7" r="0.5" fill="currentColor" />
|
||||
<circle cx="12" cy="9.5" r="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <ScanIcon />,
|
||||
title: "实时行情扫描",
|
||||
desc: "盘中实时获取涨跌停、市场温度数据,盘中盘后双模式运行,不遗漏任何一个交易时段。",
|
||||
tags: ["市场温度", "涨跌停统计", "盘中实时"],
|
||||
},
|
||||
{
|
||||
icon: <HeatmapIcon />,
|
||||
title: "板块热度分析",
|
||||
desc: "综合资金流向、涨跌幅、涨停家数、持续性四因子评分,自动判定板块阶段,识别启动期机会。",
|
||||
tags: ["资金流向", "主力占比", "热度评分"],
|
||||
},
|
||||
{
|
||||
icon: <FilterIcon />,
|
||||
title: "智能选股筛选",
|
||||
desc: "从市场环境→热门板块→个股精选三层递进筛选,结合供需关系、价格行为、趋势信号多维度评分。",
|
||||
tags: ["三层筛选", "供需分析", "趋势突破"],
|
||||
},
|
||||
{
|
||||
icon: <BrainIcon />,
|
||||
title: "AI 深度诊断",
|
||||
desc: "LLM 针对个股生成深度分析报告:趋势判断、风险提示、入场时机,从数据到决策的一步跨越。",
|
||||
tags: ["LLM 分析", "风险提示", "入场信号"],
|
||||
},
|
||||
const HIGHLIGHTS = [
|
||||
"每日市场结论",
|
||||
"主线与板块判断",
|
||||
"推荐池与自选股跟踪",
|
||||
];
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-primary relative overflow-hidden">
|
||||
{/* Ambient glow */}
|
||||
<div className="fixed top-0 right-0 w-[600px] h-[600px] bg-amber-500/5 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="fixed bottom-0 left-0 w-[400px] h-[400px] bg-amber-600/3 rounded-full blur-3xl pointer-events-none" />
|
||||
<main className="min-h-screen bg-bg-primary text-text-primary relative overflow-hidden">
|
||||
<div className="absolute inset-0 pointer-events-none bg-[radial-gradient(circle_at_top_right,rgba(245,158,11,0.08),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.01),transparent_45%)]" />
|
||||
|
||||
{/* ── Hero ── */}
|
||||
<section className="relative min-h-screen flex flex-col items-center justify-center px-6">
|
||||
<DataStreamCanvas />
|
||||
|
||||
<div className="relative z-10 text-center max-w-2xl">
|
||||
{/* Brand icon */}
|
||||
<div className="mb-8 flex justify-center">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-2xl font-bold text-white shadow-glow" style={{ boxShadow: "0 0 40px rgba(251,191,36,0.3), 0 0 80px rgba(251,191,36,0.15)" }}>
|
||||
D
|
||||
</div>
|
||||
{/* Radiating rings */}
|
||||
<div className="absolute inset-0 rounded-2xl border border-amber-500/20 animate-pulse-ring" />
|
||||
<div className="absolute -inset-3 rounded-2xl border border-amber-500/10 animate-pulse-ring" style={{ animationDelay: "0.5s" }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-text-primary mb-4">
|
||||
Dragon AI Agent
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-amber-400/80 font-medium tracking-wide mb-3">
|
||||
A 股智能筛选引擎
|
||||
</p>
|
||||
<p className="text-sm md:text-base text-text-secondary max-w-lg mx-auto mb-10 leading-relaxed">
|
||||
从全局扫描到板块定位,从资金追踪到个股精选,<br className="hidden md:block" />
|
||||
再到 AI 深度诊断 — 四维分析,一步到位。
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex items-center gap-4 justify-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-8 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold text-sm hover:from-amber-400 hover:to-amber-500 transition-all shadow-glow-sm hover:shadow-glow active:scale-95"
|
||||
style={{ boxShadow: "0 0 20px rgba(251,191,36,0.25)" }}
|
||||
>
|
||||
立即登录
|
||||
</Link>
|
||||
<a
|
||||
href="#features"
|
||||
className="px-6 py-3 text-text-secondary text-sm font-medium rounded-xl border border-border-default hover:bg-surface-3 hover:text-text-primary transition-all"
|
||||
>
|
||||
了解更多
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll hint */}
|
||||
<div className="absolute bottom-8 inset-x-0 flex justify-center animate-fade-in-up" style={{ animationDelay: "1.5s" }}>
|
||||
<div className="flex flex-col items-center gap-2 text-text-muted/60">
|
||||
<span className="text-xs">向下滚动</span>
|
||||
<div className="animate-bounce-down">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 5v14M5 12l7 7 7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Features ── */}
|
||||
<section id="features" className="relative px-6 py-20 md:py-28">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-text-primary mb-3">
|
||||
四维分析体系
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary max-w-md mx-auto">
|
||||
不只是筛选,而是从宏观到微观的完整决策链路
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{features.map((f, i) => (
|
||||
<div
|
||||
key={f.title}
|
||||
className="glass-card-static p-6 animate-fade-in-up"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
>
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-500/15 to-amber-600/10 border border-amber-500/15 flex items-center justify-center text-amber-400 shrink-0">
|
||||
{f.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-text-primary mb-1">{f.title}</h3>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">{f.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{f.tags.map((tag) => (
|
||||
<span key={tag} className="text-[11px] px-2.5 py-1 rounded-lg bg-surface-2 text-text-muted border border-border-subtle">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Process flow ── */}
|
||||
<section className="relative px-6 py-16 md:py-20">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-text-primary mb-10">
|
||||
分析流程
|
||||
</h2>
|
||||
<div className="flex flex-col md:flex-row items-center justify-center gap-4 md:gap-2">
|
||||
{[
|
||||
{ step: "01", label: "全局扫描", sub: "市场温度", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/15" },
|
||||
{ step: "02", label: "板块定位", sub: "热度排名", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/15" },
|
||||
{ step: "03", label: "个股精选", sub: "多因子评分", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/15" },
|
||||
{ step: "04", label: "AI 诊断", sub: "深度解读", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/15" },
|
||||
].map((item, i) => (
|
||||
<div key={item.step} className="flex items-center gap-2 md:gap-0">
|
||||
<div className={`flex flex-col items-center px-5 py-4 rounded-xl border ${item.bg}`}>
|
||||
<span className={`text-xs font-mono font-bold ${item.color}`}>{item.step}</span>
|
||||
<span className="text-sm font-semibold text-text-primary mt-1">{item.label}</span>
|
||||
<span className="text-xs text-text-muted mt-0.5">{item.sub}</span>
|
||||
</div>
|
||||
{i < 3 && (
|
||||
<svg className="hidden md:block text-text-muted/30 w-8 h-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Final CTA ── */}
|
||||
<section className="relative px-6 py-16 md:py-24">
|
||||
<div className="max-w-xl mx-auto text-center">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-text-primary mb-4">
|
||||
开始使用
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary mb-8">
|
||||
登录后即刻获取实时市场分析与智能推荐
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex px-10 py-3.5 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold text-sm hover:from-amber-400 hover:to-amber-500 transition-all shadow-glow-sm hover:shadow-glow active:scale-95"
|
||||
style={{ boxShadow: "0 0 20px rgba(251,191,36,0.25)" }}
|
||||
>
|
||||
登录进入
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<footer className="border-t border-border-subtle px-6 py-8">
|
||||
<div className="max-w-5xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md bg-gradient-to-br from-amber-500/30 to-amber-600/20 flex items-center justify-center text-[10px] font-bold text-amber-400">
|
||||
<div className="relative max-w-6xl mx-auto px-6 md:px-8 min-h-screen flex flex-col">
|
||||
<header className="flex items-center justify-between py-6 animate-fade-in-up">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-sm font-bold text-white shadow-glow-sm">
|
||||
D
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">Dragon AI Agent</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold tracking-tight">Dragon AI Agent</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">A 股 AI 作战系统</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted/40">
|
||||
© {new Date().getFullYear()} Dragon AI Agent
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-xs px-4 py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 transition-all duration-200 border border-amber-500/10 font-medium"
|
||||
>
|
||||
登录
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<section className="flex-1 flex items-center py-12 md:py-20">
|
||||
<div className="w-full max-w-4xl">
|
||||
<div className="text-[10px] uppercase tracking-[0.24em] text-amber-400 font-semibold animate-fade-in-up">
|
||||
AI Market Operating System
|
||||
</div>
|
||||
|
||||
<h1 className="mt-5 text-4xl md:text-6xl font-bold tracking-tight leading-[1.02] animate-fade-in-up">
|
||||
每天给你一份
|
||||
<br />
|
||||
可以直接看的市场作战结论。
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-2xl text-base md:text-lg text-text-secondary leading-relaxed animate-fade-in-up">
|
||||
从大盘、主线板块到推荐池和自选股,
|
||||
用 AI 帮你整理成一套清晰、连续、可跟踪的交易研究结果。
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mt-8 animate-fade-in-up">
|
||||
{HIGHLIGHTS.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="px-3.5 py-1.5 rounded-full bg-surface-2 border border-border-subtle text-sm text-text-secondary"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mt-10 animate-fade-in-up">
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl text-sm font-semibold hover:from-amber-400 hover:to-amber-500 transition-all shadow-glow-sm"
|
||||
>
|
||||
进入系统
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="py-6 text-xs text-text-muted animate-fade-in-up">
|
||||
面向每日交易研究的 A 股 AI 作战台
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +27,10 @@ export default function MarketTemp({ data, indices }: MarketTempProps) {
|
||||
<div className="glass-card-static p-4 sm:p-5 animate-fade-in-up">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">市场温度</h2>
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">大盘温度证据</h2>
|
||||
<p className="text-[10px] text-text-muted/60 mt-0.5">用于决定仓位上限和进攻/防守倾向</p>
|
||||
</div>
|
||||
<span className="text-[10px] sm:text-xs text-text-muted font-mono tabular-nums">{data.trade_date}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@ -34,6 +34,16 @@ function FireIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 19V5" />
|
||||
<path d="M4 19h16" />
|
||||
<path d="M7 15l3-4 3 2 4-7" />
|
||||
<path d="M17 6h3v3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagnoseIcon() {
|
||||
return (
|
||||
@ -52,6 +62,14 @@ function ChatIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function WatchlistIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
@ -96,10 +114,13 @@ export function SidebarNav() {
|
||||
|
||||
return (
|
||||
<nav className="flex-1 py-5 px-3 space-y-1">
|
||||
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="总览" />
|
||||
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐列表" />
|
||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块分析" />
|
||||
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="AI 诊断" />
|
||||
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" />
|
||||
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" />
|
||||
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="策略复盘" />
|
||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" />
|
||||
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" />
|
||||
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="个股诊断" />
|
||||
<SideNavItem href="/chat" icon={<ChatIcon />} label="作战问答" />
|
||||
{user?.role === "admin" && (
|
||||
<SideNavItem href="/settings" icon={<SettingsIcon />} label="系统设置" />
|
||||
)}
|
||||
@ -128,17 +149,17 @@ export function MobileBottomNav() {
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 md:hidden z-50 bg-bg-secondary/95 backdrop-blur-xl border-t border-border-subtle">
|
||||
<div className="flex justify-around py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
|
||||
<MobileNavItem href="/dashboard" label="总览">
|
||||
<MobileNavItem href="/dashboard" label="作战">
|
||||
<DashboardIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/recommendations" label="推荐">
|
||||
<MobileNavItem href="/recommendations" label="推荐池">
|
||||
<TargetIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/sectors" label="板块">
|
||||
<FireIcon />
|
||||
<MobileNavItem href="/strategy" label="策略">
|
||||
<StrategyIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/diagnose" label="诊断">
|
||||
<DiagnoseIcon />
|
||||
<MobileNavItem href="/watchlists" label="自选">
|
||||
<WatchlistIcon />
|
||||
</MobileNavItem>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@ -7,7 +7,7 @@ export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) {
|
||||
if (!sectors.length) {
|
||||
return (
|
||||
<div className="glass-card-static p-5">
|
||||
<h2 className="text-sm font-semibold text-text-muted mb-4">热门板块</h2>
|
||||
<h2 className="text-sm font-semibold text-text-muted mb-4">主线板块证据</h2>
|
||||
<div className="text-sm text-text-muted text-center py-6">暂无数据</div>
|
||||
</div>
|
||||
);
|
||||
@ -19,7 +19,10 @@ export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) {
|
||||
return (
|
||||
<div className="glass-card-static p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-sm font-semibold text-text-muted">热门板块</h2>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-muted">主线板块证据</h2>
|
||||
<p className="text-[10px] text-text-muted/60 mt-0.5">用于验证推荐是否站在资金和赚钱效应方向上</p>
|
||||
</div>
|
||||
{hasRealtime && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400/80">
|
||||
实时
|
||||
|
||||
@ -1,20 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { getLevelBadge, getScoreColor } from "@/lib/utils";
|
||||
import { getLevelBadge } from "@/lib/utils";
|
||||
import type { RecommendationData } from "@/lib/api";
|
||||
|
||||
export default function StockCard({ rec, showLLMLoading = false }: { rec: RecommendationData; showLLMLoading?: boolean }) {
|
||||
export default function StockCard({ rec }: { rec: RecommendationData }) {
|
||||
const badge = getLevelBadge(rec.level);
|
||||
const [aiExpanded, setAiExpanded] = useState(false);
|
||||
const aiContentRef = useRef<HTMLDivElement>(null);
|
||||
const [aiContentHeight, setAiContentHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (aiContentRef.current) {
|
||||
setAiContentHeight(aiContentRef.current.scrollHeight);
|
||||
}
|
||||
}, [aiExpanded, rec.llm_analysis]);
|
||||
|
||||
// 入场信号标签
|
||||
const signalTypeMap: Record<string, { label: string; style: string }> = {
|
||||
@ -30,14 +20,31 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
|
||||
? { label: "强中选强", style: "bg-amber-500/15 text-amber-400 border-amber-500/20" }
|
||||
: null;
|
||||
const tag = signalInfo || legacyStrategy;
|
||||
|
||||
const hasLLM = rec.llm_analysis && rec.llm_analysis !== "AI 分析暂时不可用";
|
||||
const actionPlanStyle: Record<string, string> = {
|
||||
"可操作": "bg-red-500/15 text-red-400 border-red-500/20",
|
||||
"重点关注": "bg-amber-500/15 text-amber-400 border-amber-500/20",
|
||||
"观察": "bg-surface-3 text-text-muted border-border-default",
|
||||
};
|
||||
const lifecycleLabel: Record<string, string> = {
|
||||
candidate: "观察池",
|
||||
actionable: "可操作",
|
||||
tracking: "跟踪中",
|
||||
closed_win: "盈利结束",
|
||||
closed_loss: "亏损结束",
|
||||
expired: "到期复盘",
|
||||
invalidated: "已失效",
|
||||
};
|
||||
const actionPlanCopy: Record<string, string> = {
|
||||
"可操作": "触发条件成立时才执行",
|
||||
"重点关注": "等待确认,不提前交易",
|
||||
"观察": "只记录,不主动出手",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card p-4 group">
|
||||
{/* Clickable top section — navigates to stock detail */}
|
||||
<a href={`/stock/${rec.ts_code}`} className="block">
|
||||
{/* Header: Name + Strategy + Score */}
|
||||
{/* Header: Name + Action state */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
@ -52,27 +59,75 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
|
||||
{tag.label}
|
||||
</span>
|
||||
)}
|
||||
{rec.action_plan && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${actionPlanStyle[rec.action_plan] ?? actionPlanStyle["观察"]}`}>
|
||||
{rec.action_plan}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted mt-1 font-mono tabular-nums">
|
||||
{rec.ts_code} · {rec.sector}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-3">
|
||||
<div className={`text-xl font-bold font-mono tabular-nums tracking-tight ${getScoreColor(rec.score)}`}>
|
||||
{rec.score}
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider">
|
||||
AI 决策
|
||||
</div>
|
||||
<div className={`text-[10px] font-medium ${badge.text}`}>
|
||||
{rec.level}
|
||||
<div className="text-xs text-text-secondary mt-0.5">
|
||||
{rec.action_plan ?? "观察"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score dimension bars */}
|
||||
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||
<ScoreBar label="供需" value={rec.supply_demand_score ?? 0} weight="50%" />
|
||||
<ScoreBar label="形态" value={rec.price_action_score ?? 0} weight="40%" />
|
||||
<ScoreBar label="趋势" value={rec.technical_score} weight="10%" />
|
||||
<ScoreBar label="位置" value={rec.position_score ?? 50} weight="防追高" />
|
||||
{(rec.action_plan || rec.trigger_condition || rec.invalidation_condition || rec.suggested_position_pct) && (
|
||||
<div className="mb-3 rounded-xl bg-surface-1/70 border border-border-subtle p-3">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">AI 操作计划</div>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${rec.action_plan ? actionPlanStyle[rec.action_plan] ?? actionPlanStyle["观察"] : actionPlanStyle["观察"]}`}>
|
||||
{rec.action_plan ? actionPlanCopy[rec.action_plan] ?? rec.action_plan : "等待结论"}
|
||||
</span>
|
||||
</div>
|
||||
{rec.trigger_condition && (
|
||||
<div className="text-[11px] text-text-secondary leading-relaxed line-clamp-2">
|
||||
触发:{rec.trigger_condition}
|
||||
</div>
|
||||
)}
|
||||
{rec.invalidation_condition && (
|
||||
<div className="text-[11px] text-text-muted leading-relaxed line-clamp-2 mt-1">
|
||||
失效:{rec.invalidation_condition}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2 text-[10px] text-text-muted">
|
||||
{rec.suggested_position_pct != null && (
|
||||
<span className="rounded-md bg-surface-2 px-2 py-1">
|
||||
仓位 {rec.suggested_position_pct}%
|
||||
</span>
|
||||
)}
|
||||
{rec.review_after_days ? (
|
||||
<span className="rounded-md bg-surface-2 px-2 py-1">
|
||||
{rec.review_after_days}日复盘
|
||||
</span>
|
||||
) : null}
|
||||
<span className={`rounded-md px-2 py-1 ${badge.bg} ${badge.text}`}>
|
||||
{rec.level}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 rounded-xl bg-surface-1/60 border border-border-subtle px-3 py-2.5">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">证据维度</div>
|
||||
<span className="text-[10px] text-text-muted font-mono tabular-nums">
|
||||
参考 {rec.score.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<ScoreBar label="供需" value={rec.supply_demand_score ?? 0} weight="证据" />
|
||||
<ScoreBar label="形态" value={rec.price_action_score ?? 0} weight="证据" />
|
||||
<ScoreBar label="趋势" value={rec.technical_score} weight="证据" />
|
||||
<ScoreBar label="位置" value={rec.position_score ?? 50} weight="风控" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price reference */}
|
||||
@ -93,6 +148,38 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rec.tracking && (
|
||||
<div className="mb-3 bg-surface-1/60 rounded-lg px-3 py-2 border border-border-subtle">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] text-text-muted">
|
||||
生命周期 · {lifecycleLabel[rec.lifecycle_status || ""] ?? rec.lifecycle_status ?? "跟踪"}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted font-mono tabular-nums">
|
||||
{rec.tracking.days_since_recommendation ?? 0}日 · {rec.tracking.track_date}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<TrackingMetric
|
||||
label="当前"
|
||||
value={rec.tracking.pct_from_entry}
|
||||
/>
|
||||
<TrackingMetric
|
||||
label="最大浮盈"
|
||||
value={rec.tracking.max_return_pct}
|
||||
/>
|
||||
<TrackingMetric
|
||||
label="最大回撤"
|
||||
value={rec.tracking.max_drawdown_pct}
|
||||
/>
|
||||
</div>
|
||||
{rec.tracking.review_note && (
|
||||
<div className="text-[11px] text-text-muted leading-relaxed mt-2 line-clamp-2">
|
||||
{rec.tracking.review_note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reasons */}
|
||||
<div className="space-y-1.5">
|
||||
{rec.reasons.slice(0, 3).map((r, i) => (
|
||||
@ -104,46 +191,19 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* ── AI Analysis — separate from the clickable link area ── */}
|
||||
{hasLLM ? (
|
||||
<div className="mt-3 border-t border-border-subtle pt-3">
|
||||
<button
|
||||
onClick={() => setAiExpanded(!aiExpanded)}
|
||||
className="w-full flex items-center gap-2 text-xs text-cyan-400/80 font-medium hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${aiExpanded ? "rotate-90" : ""}`}>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
AI 分析
|
||||
{rec.llm_score != null && (
|
||||
<span className="ml-auto font-mono tabular-nums">
|
||||
<span className={`font-bold ${rec.llm_score >= 7 ? "text-amber-400" : rec.llm_score >= 5 ? "text-cyan-400" : "text-text-muted"}`}>
|
||||
{rec.llm_score}
|
||||
</span>
|
||||
<span className="text-text-muted/40">/10</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className="overflow-hidden transition-[max-height] duration-300 ease-out"
|
||||
style={{ maxHeight: aiExpanded ? aiContentHeight + 20 : 0 }}
|
||||
>
|
||||
<div ref={aiContentRef} className="text-xs text-text-secondary leading-relaxed whitespace-pre-line mt-2 pl-1">
|
||||
<MarkdownText text={rec.llm_analysis ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-border-subtle pt-2 flex items-center justify-between gap-3 text-[11px]">
|
||||
<div className="text-text-muted">
|
||||
推演记录在详情页归档
|
||||
{rec.llm_score != null && (
|
||||
<span className="ml-2 font-mono tabular-nums text-cyan-400/80">
|
||||
AI {rec.llm_score}/10
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : rec.llm_analysis === "AI 分析暂时不可用" ? (
|
||||
<div className="mt-3 border-t border-border-subtle pt-2 text-xs text-text-muted/40 flex items-center gap-1.5">
|
||||
<span className="w-1 h-1 rounded-full bg-text-muted/20" />
|
||||
AI 分析暂不可用
|
||||
</div>
|
||||
) : showLLMLoading ? (
|
||||
<div className="mt-3 border-t border-border-subtle pt-2 text-xs text-text-muted flex items-center gap-2">
|
||||
<span className="inline-block w-3 h-3 border border-cyan-400/30 border-t-cyan-400/80 rounded-full animate-spin" />
|
||||
AI 分析中...
|
||||
</div>
|
||||
) : null}
|
||||
<a href={`/stock/${rec.ts_code}`} className="shrink-0 text-cyan-400/80 hover:text-cyan-400 transition-colors">
|
||||
查看推演
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Risk note */}
|
||||
{rec.risk_note && (
|
||||
@ -155,86 +215,6 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
|
||||
);
|
||||
}
|
||||
|
||||
/** Markdown 文本渲染(处理标题、粗体、列表、分隔线等) */
|
||||
function MarkdownText({ text }: { text: string }) {
|
||||
const lines = text.split("\n");
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, i) => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// ### / ## 标题
|
||||
const headingMatch = trimmed.match(/^(#{1,3})\s+(.+)/);
|
||||
if (headingMatch) {
|
||||
return (
|
||||
<div key={i} className="text-accent-cyan/70 font-semibold mt-2 first:mt-0">
|
||||
{headingMatch[2].replace(/\*\*/g, "")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 空行
|
||||
if (!trimmed) {
|
||||
return <div key={i} className="h-1" />;
|
||||
}
|
||||
|
||||
// 分隔线 --- 或 ***
|
||||
if (/^[-*_]{3,}$/.test(trimmed)) {
|
||||
return <div key={i} className="border-t border-border-default my-1.5" />;
|
||||
}
|
||||
|
||||
// 无序列表 - 开头
|
||||
if (trimmed.startsWith("- ") || trimmed.startsWith("· ")) {
|
||||
const content = trimmed.slice(2);
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-1.5">
|
||||
<span className="w-1 h-1 rounded-full bg-accent-cyan/40 mt-[7px] shrink-0" />
|
||||
<span>{renderInlineFormat(content)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 有序列表 1. 开头
|
||||
const olMatch = trimmed.match(/^(\d+)[.、)]\s+(.+)/);
|
||||
if (olMatch) {
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-1.5">
|
||||
<span className="text-accent-cyan/50 font-mono text-[10px] mt-[2px] shrink-0">{olMatch[1]}.</span>
|
||||
<span>{renderInlineFormat(olMatch[2])}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 普通文本
|
||||
return <div key={i}>{renderInlineFormat(trimmed)}</div>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 处理行内格式:**粗体**、*斜体*、`代码` */
|
||||
function renderInlineFormat(text: string) {
|
||||
// 先处理 `代码`
|
||||
const parts = text.split(/(`[^`]+`)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith("`") && part.endsWith("`")) {
|
||||
return (
|
||||
<code key={i} className="bg-surface-4 px-1 py-0.5 rounded text-accent-cyan/90 font-mono text-[11px]">
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
// 再处理 **粗体** 和 *斜体*
|
||||
const boldParts = part.split(/(\*\*[^*]+\*\*)/g);
|
||||
return boldParts.map((bp, j) => {
|
||||
if (bp.startsWith("**") && bp.endsWith("**")) {
|
||||
return <span key={`${i}-${j}`} className="font-semibold text-text-primary">{bp.slice(2, -2)}</span>;
|
||||
}
|
||||
return <span key={`${i}-${j}`}>{bp}</span>;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ScoreBar({ label, value, weight }: { label: string; value: number; weight?: string }) {
|
||||
const width = Math.min(value, 100);
|
||||
const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low";
|
||||
@ -253,3 +233,16 @@ function ScoreBar({ label, value, weight }: { label: string; value: number; weig
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackingMetric({ label, value }: { label: string; value: number | null }) {
|
||||
const num = value ?? 0;
|
||||
const color = num > 0 ? "text-red-400" : num < 0 ? "text-emerald-400" : "text-text-secondary";
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted/60 mb-0.5">{label}</div>
|
||||
<div className={`text-xs font-mono tabular-nums font-semibold ${color}`}>
|
||||
{num > 0 ? "+" : ""}{num.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -70,6 +70,29 @@ export async function deleteAPI<T>(path: string): Promise<T> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function patchAPI<T>(path: string, body?: unknown): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized();
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || `API error: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface MarketTemperatureData {
|
||||
trade_date: string;
|
||||
temperature: number;
|
||||
@ -117,6 +140,26 @@ export interface RecommendationData {
|
||||
llm_score?: number | null;
|
||||
scan_session: string;
|
||||
created_at: string | null;
|
||||
entry_timing?: string;
|
||||
action_plan?: "可操作" | "重点关注" | "观察";
|
||||
trigger_condition?: string;
|
||||
invalidation_condition?: string;
|
||||
suggested_position_pct?: number;
|
||||
review_after_days?: number;
|
||||
lifecycle_status?: string;
|
||||
data_freshness?: string;
|
||||
tracking?: RecommendationTrackingSummary | null;
|
||||
}
|
||||
|
||||
export interface RecommendationTrackingSummary {
|
||||
current_price: number | null;
|
||||
pct_from_entry: number | null;
|
||||
max_return_pct: number | null;
|
||||
max_drawdown_pct: number | null;
|
||||
days_since_recommendation: number | null;
|
||||
close_reason: string;
|
||||
review_note: string;
|
||||
track_date: string;
|
||||
}
|
||||
|
||||
export interface LeadingStock {
|
||||
@ -150,6 +193,14 @@ export interface SectorData {
|
||||
export interface LatestResult {
|
||||
market_temperature: MarketTemperatureData | null;
|
||||
recommendations: RecommendationData[];
|
||||
strategy_profile?: {
|
||||
strategy_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
buy_threshold?: number;
|
||||
min_score?: number;
|
||||
notes?: string[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface DayGroup {
|
||||
@ -168,8 +219,11 @@ export interface PerformanceStats {
|
||||
winning: number;
|
||||
win_rate: number;
|
||||
avg_return: number;
|
||||
avg_max_return: number;
|
||||
avg_max_drawdown: number;
|
||||
hit_target_count: number;
|
||||
hit_stop_count: number;
|
||||
lifecycle_counts: Record<string, number>;
|
||||
details: TrackedRecommendation[];
|
||||
}
|
||||
|
||||
@ -183,6 +237,13 @@ export interface TrackedRecommendation {
|
||||
hit_target: boolean;
|
||||
hit_stop_loss: boolean;
|
||||
status: string;
|
||||
action_plan?: string;
|
||||
lifecycle_status?: string;
|
||||
max_return_pct?: number;
|
||||
max_drawdown_pct?: number;
|
||||
days_since_recommendation?: number;
|
||||
close_reason?: string;
|
||||
review_note?: string;
|
||||
track_date: string;
|
||||
}
|
||||
|
||||
@ -198,6 +259,178 @@ export interface DailyReviewResponse {
|
||||
reviews: DailyReview[];
|
||||
}
|
||||
|
||||
// ---------- Strategy Board ----------
|
||||
|
||||
export interface StrategyFocus {
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface StrategySectorFocus {
|
||||
sector_name: string;
|
||||
stage: string;
|
||||
heat_score: number;
|
||||
pct_change: number;
|
||||
limit_up_count: number;
|
||||
view: string;
|
||||
}
|
||||
|
||||
export interface StrategyStat {
|
||||
name: string;
|
||||
count: number;
|
||||
win_rate: number;
|
||||
avg_return: number;
|
||||
avg_max_return: number;
|
||||
avg_max_drawdown: number;
|
||||
hit_target: number;
|
||||
hit_stop: number;
|
||||
}
|
||||
|
||||
export interface StrategyAdjustment {
|
||||
target: string;
|
||||
action: string;
|
||||
reason: string;
|
||||
confidence: string;
|
||||
}
|
||||
|
||||
export interface StrategyIterationReport {
|
||||
generated_at: string;
|
||||
sample_size: number;
|
||||
summary: string;
|
||||
strategy_stats: StrategyStat[];
|
||||
signal_stats: StrategyStat[];
|
||||
failure_patterns: string[];
|
||||
adjustment_suggestions: StrategyAdjustment[];
|
||||
ai_analysis: string;
|
||||
generated_by: string;
|
||||
}
|
||||
|
||||
export interface StrategyBoard {
|
||||
trade_date: string;
|
||||
market_regime: string;
|
||||
risk_level: string;
|
||||
action_bias: string;
|
||||
position_suggestion: string;
|
||||
summary: string;
|
||||
recommended_mode: string;
|
||||
strategy_focus: StrategyFocus[];
|
||||
watch_sectors: StrategySectorFocus[];
|
||||
avoid_rules: string[];
|
||||
iteration_notes: string[];
|
||||
iteration_report?: StrategyIterationReport;
|
||||
metrics: {
|
||||
temperature?: number;
|
||||
recommendation_count?: number;
|
||||
actionable_count?: number;
|
||||
watch_count?: number;
|
||||
avg_score?: number;
|
||||
win_rate?: number;
|
||||
avg_return?: number;
|
||||
tracked?: number;
|
||||
};
|
||||
ai_review: string;
|
||||
generated_by: string;
|
||||
}
|
||||
|
||||
export interface StockThesisTracking {
|
||||
track_date: string;
|
||||
current_price: number | null;
|
||||
pct_from_entry: number | null;
|
||||
max_return_pct: number | null;
|
||||
max_drawdown_pct: number | null;
|
||||
days_since_recommendation: number | null;
|
||||
hit_target: boolean;
|
||||
hit_stop_loss: boolean;
|
||||
close_reason: string;
|
||||
review_note: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface StockThesisDiagnosis {
|
||||
id: number;
|
||||
diagnosis: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface StockThesisPoint {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface StockThesisResponse {
|
||||
ts_code: string;
|
||||
name: string;
|
||||
has_recommendation: boolean;
|
||||
recommendation: RecommendationData | null;
|
||||
latest_tracking: StockThesisTracking | null;
|
||||
tracking_history: StockThesisTracking[];
|
||||
diagnoses: StockThesisDiagnosis[];
|
||||
decision_points: StockThesisPoint[];
|
||||
data_freshness: {
|
||||
recommendation_created_at: string;
|
||||
tracking_date: string;
|
||||
status: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpsStatusResponse {
|
||||
scan_running: boolean;
|
||||
scan_mode: string;
|
||||
is_trading: boolean;
|
||||
data_freshness: {
|
||||
market_trade_date: string;
|
||||
sector_trade_date: string;
|
||||
tracking_trade_date: string;
|
||||
last_recommendation_created_at: string;
|
||||
last_tracking_created_at: string;
|
||||
last_market_created_at: string;
|
||||
last_sector_created_at: string;
|
||||
last_review_created_at: string;
|
||||
status: string;
|
||||
message: string;
|
||||
generated_at: string;
|
||||
};
|
||||
actions: {
|
||||
key: string;
|
||||
label: string;
|
||||
admin_only: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface WatchlistItem {
|
||||
id: number;
|
||||
ts_code: string;
|
||||
name: string;
|
||||
note: string;
|
||||
watch_group?: "observe" | "focus" | "candidate" | "holding";
|
||||
cost_price?: number | null;
|
||||
created_at: string;
|
||||
conclusion?: string;
|
||||
advice?: string;
|
||||
trigger_condition?: string;
|
||||
risk_note?: string;
|
||||
summary?: string;
|
||||
analysis_created_at?: string;
|
||||
}
|
||||
|
||||
export interface WatchlistHistoryItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
watchlist_id: number;
|
||||
ts_code: string;
|
||||
name: string;
|
||||
conclusion: string;
|
||||
advice: string;
|
||||
trigger_condition: string;
|
||||
risk_note: string;
|
||||
summary: string;
|
||||
full_analysis: string;
|
||||
score_reference: number;
|
||||
analysis_mode: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ---------- Sector Rotation ----------
|
||||
|
||||
export interface SectorRotationData {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user