最大更新

This commit is contained in:
aaron 2026-04-22 11:02:19 +08:00
parent 865db50369
commit b699b185fc
62 changed files with 5717 additions and 1547 deletions

View File

@ -6,10 +6,11 @@ POST /api/chat/stream - SSE 流式对话
import json import json
import logging import logging
from fastapi import APIRouter from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from app.core.deps import get_current_user
from app.llm.chat_agent import chat_stream from app.llm.chat_agent import chat_stream
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -26,13 +27,13 @@ class ChatRequest(BaseModel):
@router.post("/stream") @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""" """流式对话接口SSE"""
messages = [{"role": m.role, "content": m.content} for m in req.messages] messages = [{"role": m.role, "content": m.content} for m in req.messages]
async def event_generator(): async def event_generator():
try: 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) data = json.dumps(msg, ensure_ascii=False)
yield f"data: {data}\n\n" yield f"data: {data}\n\n"
yield "data: [DONE]\n\n" yield "data: [DONE]\n\n"

View File

@ -1,11 +1,14 @@
"""市场概览 API""" """市场概览 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.tushare_client import tushare_client
from app.data import tencent_client from app.data import tencent_client
from app.engine.recommender import get_latest_recommendations from app.engine.recommender import get_latest_recommendations
from app.config import is_trading_hours, is_market_session 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"]) router = APIRouter(prefix="/api/market", tags=["market"])
@ -73,8 +76,112 @@ async def get_daily_review():
return {"reviews": reviews} 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") @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 from app.llm.daily_review import generate_review
result = await generate_review() result = await generate_review()

View File

@ -4,7 +4,7 @@ import asyncio
import logging import logging
import traceback import traceback
from datetime import datetime from datetime import datetime
from fastapi import APIRouter from fastapi import APIRouter, Depends
from app.engine.recommender import ( from app.engine.recommender import (
refresh_recommendations, refresh_recommendations,
@ -13,6 +13,7 @@ from app.engine.recommender import (
get_performance_stats, get_performance_stats,
) )
from app.config import is_trading_hours from app.config import is_trading_hours
from app.core.deps import get_current_admin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -60,6 +61,13 @@ async def get_latest():
"risk_note": r.risk_note, "risk_note": r.risk_note,
"llm_analysis": r.llm_analysis, "llm_analysis": r.llm_analysis,
"entry_timing": r.entry_timing, "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, "llm_score": r.llm_score,
"strategy": r.strategy, "strategy": r.strategy,
"entry_signal_type": r.entry_signal_type, "entry_signal_type": r.entry_signal_type,
@ -69,11 +77,12 @@ async def get_latest():
for r in result.get("recommendations", []) for r in result.get("recommendations", [])
], ],
"scan_mode": result.get("scan_mode", "unknown"), "scan_mode": result.get("scan_mode", "unknown"),
"strategy_profile": result.get("strategy_profile"),
} }
@router.post("/refresh") @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 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") @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 from app.engine.recommender import _update_tracking
await _update_tracking() await _update_tracking()

View File

@ -5,7 +5,7 @@ import logging
import traceback import traceback
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter from fastapi import APIRouter, Query
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
from app.data.tushare_client import tushare_client from app.data.tushare_client import tushare_client
@ -19,6 +19,188 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/stocks", tags=["stocks"]) 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") @router.get("/{ts_code}/quote")
async def get_quote(ts_code: str): async def get_quote(ts_code: str):
"""获取个股实时行情""" """获取个股实时行情"""
@ -86,20 +268,6 @@ async def get_capital_flow(ts_code: str, days: int = 10):
return records 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") @router.get("/{ts_code}/diagnose/history")
async def get_diagnose_history(ts_code: str): async def get_diagnose_history(ts_code: str):
"""获取个股最近5次诊断历史""" """获取个股最近5次诊断历史"""
@ -123,6 +291,7 @@ async def get_diagnose_history(ts_code: str):
"id": r["id"], "id": r["id"],
"ts_code": r["ts_code"], "ts_code": r["ts_code"],
"name": r["name"], "name": r["name"],
"diagnosis_mode": r.get("diagnosis_mode", "entry"),
"diagnosis": r["diagnosis"], "diagnosis": r["diagnosis"],
"created_at": str(r["created_at"]), "created_at": str(r["created_at"]),
}) })
@ -133,7 +302,7 @@ async def get_diagnose_history(ts_code: str):
@router.post("/{ts_code}/diagnose") @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 流式返回)""" """AI 诊断个股SSE 流式返回)"""
from app.config import settings from app.config import settings
if not settings.deepseek_api_key: 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 " "SELECT id, ts_code, name, diagnosis, created_at "
"FROM stock_diagnoses " "FROM stock_diagnoses "
"WHERE ts_code = :code " "WHERE ts_code = :code "
"AND diagnosis_mode = :mode "
"AND created_at >= datetime('now', '-30 minutes', 'localtime') " "AND created_at >= datetime('now', '-30 minutes', 'localtime') "
"ORDER BY created_at DESC LIMIT 1" "ORDER BY created_at DESC LIMIT 1"
), ),
{"code": ts_code}, {"code": ts_code, "mode": mode},
) )
recent_row = result.fetchone() recent_row = result.fetchone()
if recent_row: if recent_row:
@ -342,7 +512,25 @@ async def diagnose_stock(ts_code: str):
except Exception: except Exception:
pass 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}) 股票: {ts_code} ({basic_info})
{quote_str} {quote_str}
@ -358,23 +546,44 @@ async def diagnose_stock(ts_code: str):
{sector_str} {sector_str}
重要提示 重要提示
1. 趋势评分是推荐体系的技术面核心分数均线排列40+高低点结构35+MA20方向25=满分100辅助信号计数仅供参考不参与主评分 1. 你不是在写传统研报而是在给交易作战台输出结构化会诊意见
2. 位置安全评分高(>80)表示股价处于相对低位(<40)表示可能追高 2. 如果有推荐体系评分操作计划跟踪信息请优先沿用当前推荐体系而不是另起一套标准
3. 如果有推荐体系评分请作为主要分析依据趋势评分和信号计数从不同维度描述技术面状态 3. 趋势评分是推荐体系的技术面核心分数均线排列40+高低点结构35+MA20方向25=满分100辅助信号计数仅供参考不参与主评分
4. 位置安全评分高(>80)表示股价处于相对低位(<40)表示可能追高
5. 板块信息和推荐体系信息优先级高于单一技术指标
{freshness_note} {freshness_note}
请从以下维度分析Markdown格式简洁专业 请严格按以下 Markdown 结构输出不要写成泛泛长文
## 综合评级
给出1-5星评级和一句话总结综合趋势评分位置安全和供需形态
## 技术面分析 ## 当前结论
趋势方向均线关系支撑压力量价配合优先参考趋势评分而非信号计数 - 结论: 只能从可操作 / 重点关注 / 观察 / 回避中选一个
- 一句话判断: 用一句话解释为什么
- 适配模式: 说明更适合启动试错分歧回流趋势跟随还是只观察
## 资金面分析 ## 核心逻辑
主力资金态度板块联动效应 - 市场环境: 当前大盘和风格是否支持这只票
- 板块位置: 所属板块是主线次主线还是观察线
- 个股角色: 龙头 / 跟风 / 独立逻辑 / 非核心
## 操作建议 ## 执行动作
适合什么类型的投资者入场时机风险提示""" - 触发条件: 什么情况下才可以行动
- 失效条件: 什么情况下放弃
- 仓位建议: 用低 / / 或百分比表达
- 适合谁: 适合激进试错低吸等待还是不适合参与
## 风险清单
- 风险1:
- 风险2:
- 风险3:
## 复盘问题
- 如果后续走势不符合预期优先检查哪两个问题
要求
- 结论必须明确不能模糊两可
- 少写形容词多写交易判断
- 不要重复原始数据
- 文字保持简洁避免旧式研报语气"""
# ── SSE 流式返回 ── # ── SSE 流式返回 ──
async def _stream_diagnosis(): async def _stream_diagnosis():
@ -384,7 +593,7 @@ async def diagnose_stock(ts_code: str):
stream = await client.chat.completions.create( stream = await client.chat.completions.create(
model=settings.deepseek_model, model=settings.deepseek_model,
messages=[ messages=[
{"role": "system", "content": "你是一位专业的A股分析师擅长技术面和资金面分析。回复使用Markdown格式简洁专业客观理性"}, {"role": "system", "content": "你是A股AI投研作战台中的个股会诊模块。你的职责不是写传统长文研报而是基于市场环境、板块地位、推荐体系评分和跟踪结果输出可执行、结构化的交易会诊意见。回复必须使用Markdown结论明确强调触发条件、失效条件、仓位和风险"},
{"role": "user", "content": user_msg}, {"role": "user", "content": user_msg},
], ],
max_tokens=1500, max_tokens=1500,
@ -407,6 +616,7 @@ async def diagnose_stock(ts_code: str):
tables.stock_diagnoses_table.insert().values( tables.stock_diagnoses_table.insert().values(
ts_code=ts_code, ts_code=ts_code,
name=stock_name or ts_code, name=stock_name or ts_code,
diagnosis_mode=mode,
diagnosis=full_content, 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({'error': error_msg}, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'done': True, 'ts_code': ts_code}, 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")

View 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]

View File

@ -133,7 +133,46 @@ class Recommendation(BaseModel):
strategy: str = "trend_breakout" # trend_breakout / momentum(旧) / potential(旧) strategy: str = "trend_breakout" # trend_breakout / momentum(旧) / potential(旧)
entry_signal_type: str = "none" # breakout / pullback / launch / none entry_signal_type: str = "none" # breakout / pullback / launch / none
entry_timing: str = "" # 进场时机建议(盘中适用) 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_analysis: str = "" # LLM 深度分析
llm_score: float | None = None # AI 评分 1-10 llm_score: float | None = None # AI 评分 1-10
scan_session: str = "" scan_session: str = ""
created_at: datetime | None = None 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"

View File

@ -40,12 +40,21 @@ async def init_db():
"ALTER TABLE recommendations ADD COLUMN price_action_score REAL DEFAULT 0", "ALTER TABLE recommendations ADD COLUMN price_action_score REAL DEFAULT 0",
"ALTER TABLE recommendations ADD COLUMN position_score REAL", "ALTER TABLE recommendations ADD COLUMN position_score REAL",
"ALTER TABLE recommendations ADD COLUMN valuation_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 llm_analysis TEXT DEFAULT ''",
"ALTER TABLE recommendations ADD COLUMN strategy TEXT DEFAULT 'momentum'", "ALTER TABLE recommendations ADD COLUMN strategy TEXT DEFAULT 'momentum'",
"ALTER TABLE recommendations ADD COLUMN llm_score REAL", "ALTER TABLE recommendations ADD COLUMN llm_score REAL",
"ALTER TABLE market_temperature ADD COLUMN max_streak INTEGER", "ALTER TABLE market_temperature ADD COLUMN max_streak INTEGER",
"ALTER TABLE market_temperature ADD COLUMN broken_rate REAL", "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_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 stage TEXT",
"ALTER TABLE sector_heat ADD COLUMN days_continuous INTEGER", "ALTER TABLE sector_heat ADD COLUMN days_continuous INTEGER",
"ALTER TABLE sector_heat ADD COLUMN member_count 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 pct_trend TEXT",
"ALTER TABLE sector_heat ADD COLUMN turnover_avg REAL", "ALTER TABLE sector_heat ADD COLUMN turnover_avg REAL",
"ALTER TABLE sector_heat ADD COLUMN main_force_ratio 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: try:
await conn.execute( await conn.execute(

View File

@ -27,9 +27,18 @@ recommendations_table = Table(
Column("target_price", Float), Column("target_price", Float),
Column("stop_loss", Float), Column("stop_loss", Float),
Column("reasons", Text), 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("llm_analysis", Text, default=""),
Column("strategy", Text, default="trend_breakout"), Column("strategy", Text, default="trend_breakout"),
Column("entry_signal_type", Text, default="none"), Column("entry_signal_type", Text, default="none"),
Column("entry_timing", Text, default=""),
Column("llm_score", Float, default=None), Column("llm_score", Float, default=None),
Column("scan_session", Text), Column("scan_session", Text),
Column("created_at", DateTime, server_default=func.now()), Column("created_at", DateTime, server_default=func.now()),
@ -76,8 +85,15 @@ recommendation_tracking_table = Table(
Column("track_date", Text, nullable=False), Column("track_date", Text, nullable=False),
Column("current_price", Float), Column("current_price", Float),
Column("pct_from_entry", 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_target", Boolean, default=False),
Column("hit_stop_loss", 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("status", Text, default="active"),
Column("created_at", DateTime, server_default=func.now()), 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("id", Integer, primary_key=True, autoincrement=True),
Column("ts_code", Text, nullable=False), Column("ts_code", Text, nullable=False),
Column("name", Text, nullable=False), Column("name", Text, nullable=False),
Column("diagnosis_mode", Text, default="entry"),
Column("diagnosis", Text, nullable=False), Column("diagnosis", Text, nullable=False),
Column("created_at", DateTime, server_default=func.now()), 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_table = Table(
"error_logs", metadata, "error_logs", metadata,
Column("id", Integer, primary_key=True, autoincrement=True), Column("id", Integer, primary_key=True, autoincrement=True),

View File

@ -62,11 +62,12 @@ async def _update_tracking():
# 查找所有活跃的推荐(有 entry_price 且未被标记为 closed # 查找所有活跃的推荐(有 entry_price 且未被标记为 closed
result = await db.execute( result = await db.execute(
text( 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 " "FROM recommendations "
"WHERE entry_price IS NOT NULL " "WHERE entry_price IS NOT NULL "
"AND entry_price > 0 " "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) " "AND date(created_at) <= date(:today) "
"ORDER BY created_at DESC LIMIT 50" "ORDER BY created_at DESC LIMIT 50"
), ),
@ -88,15 +89,44 @@ async def _update_tracking():
tracked = 0 tracked = 0
for r in rows: 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) current_price = price_map.get(ts_code)
if current_price is None or entry_price is None or entry_price <= 0: if current_price is None or entry_price is None or entry_price <= 0:
continue continue
pct = round((current_price - entry_price) / entry_price * 100, 2) track_metrics = _calculate_tracking_metrics(
hit_target = target_price and current_price >= target_price ts_code=ts_code,
hit_stop = stop_loss and current_price <= stop_loss entry_price=float(entry_price),
status = "closed" if (hit_target or hit_stop) else "active" 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( exists = await db.execute(
@ -115,11 +145,25 @@ async def _update_tracking():
track_date=trade_date, track_date=trade_date,
current_price=current_price, current_price=current_price,
pct_from_entry=pct, 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_target=hit_target,
hit_stop_loss=hit_stop, hit_stop_loss=hit_stop,
close_reason=close_reason,
review_note=review_note,
status=status, status=status,
) )
) )
await db.execute(
text(
"UPDATE recommendations SET lifecycle_status = :status "
"WHERE id = :rid"
),
{"status": new_lifecycle, "rid": rec_id},
)
tracked += 1 tracked += 1
await db.commit() await db.commit()
@ -131,6 +175,93 @@ async def _update_tracking():
await log_error("recommender", f"更新推荐跟踪失败: {e}", detail=traceback.format_exc()) 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: async def get_performance_stats() -> dict:
"""获取推荐胜率统计""" """获取推荐胜率统计"""
try: try:
@ -200,13 +331,44 @@ async def get_performance_stats() -> dict:
) )
hit_stop_count = result.scalar() or 0 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( result = await db.execute(
text( text(
"SELECT r.ts_code, r.name, r.signal, r.entry_price, " "SELECT r.ts_code, r.name, r.signal, r.entry_price, "
" r.target_price, r.stop_loss, r.entry_signal_type, r.score, " " 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, " " 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 " "FROM recommendations r "
"INNER JOIN recommendation_tracking t ON t.recommendation_id = r.id " "INNER JOIN recommendation_tracking t ON t.recommendation_id = r.id "
"INNER JOIN (" "INNER JOIN ("
@ -224,12 +386,19 @@ async def get_performance_stats() -> dict:
"name": r["name"], "name": r["name"],
"signal": r["signal"], "signal": r["signal"],
"entry_signal_type": r["entry_signal_type"], "entry_signal_type": r["entry_signal_type"],
"action_plan": r["action_plan"],
"lifecycle_status": r["lifecycle_status"],
"score": r["score"], "score": r["score"],
"entry_price": r["entry_price"], "entry_price": r["entry_price"],
"target_price": r["target_price"], "target_price": r["target_price"],
"stop_loss": r["stop_loss"], "stop_loss": r["stop_loss"],
"current_price": r["current_price"], "current_price": r["current_price"],
"pct_from_entry": r["pct_from_entry"], "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"], "track_date": r["track_date"],
"hit_target": bool(r["hit_target"]), "hit_target": bool(r["hit_target"]),
"hit_stop_loss": bool(r["hit_stop_loss"]), "hit_stop_loss": bool(r["hit_stop_loss"]),
@ -244,8 +413,11 @@ async def get_performance_stats() -> dict:
"winning": winning, "winning": winning,
"win_rate": win_rate, "win_rate": win_rate,
"avg_return": avg_return, "avg_return": avg_return,
"avg_max_return": avg_max_return,
"avg_max_drawdown": avg_max_drawdown,
"hit_target_count": hit_target_count, "hit_target_count": hit_target_count,
"hit_stop_count": hit_stop_count, "hit_stop_count": hit_stop_count,
"lifecycle_counts": lifecycle_counts,
"details": details, "details": details,
} }
except Exception as e: except Exception as e:
@ -254,8 +426,9 @@ async def get_performance_stats() -> dict:
await log_error("recommender", f"获取胜率统计失败: {e}", detail=traceback.format_exc()) await log_error("recommender", f"获取胜率统计失败: {e}", detail=traceback.format_exc())
return { return {
"total_recommendations": 0, "tracked": 0, "winning": 0, "total_recommendations": 0, "tracked": 0, "winning": 0,
"win_rate": 0, "avg_return": 0, "hit_target_count": 0, "win_rate": 0, "avg_return": 0, "avg_max_return": 0,
"hit_stop_count": 0, "details": [], "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 去重(每天取最新一条) # 查询所有历史推荐,按 ts_code 去重(每天取最新一条)
stmt = text( stmt = text(
"SELECT * FROM recommendations " "SELECT r.*, "
"WHERE created_at >= :start " "latest_t.current_price AS latest_current_price, "
"AND score >= 60 " "latest_t.pct_from_entry AS latest_pct_from_entry, "
"AND id IN (" "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 " " SELECT MAX(id) FROM recommendations "
" WHERE created_at >= :start " " WHERE created_at >= :start "
" GROUP BY date(created_at), ts_code" " 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}) result = await db.execute(stmt, {"start": start})
rows = result.fetchall() rows = result.fetchall()
@ -324,11 +513,29 @@ async def get_recommendation_history(days: int = 7) -> list[dict]:
"target_price": r["target_price"], "target_price": r["target_price"],
"stop_loss": r["stop_loss"], "stop_loss": r["stop_loss"],
"reasons": json.loads(r["reasons"]) if r["reasons"] else [], "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", "strategy": r.get("strategy") or "trend_breakout",
"entry_signal_type": r.get("entry_signal_type") or "none", "entry_signal_type": r.get("entry_signal_type") or "none",
"llm_analysis": r.get("llm_analysis") or "", "llm_analysis": r.get("llm_analysis") or "",
"llm_score": r.get("llm_score"), "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 "", "scan_session": r["scan_session"] or "",
"created_at": created_at_str, "created_at": created_at_str,
} }
@ -370,7 +577,7 @@ async def _save_to_db(result: dict):
"""将推荐结果保存到数据库""" """将推荐结果保存到数据库"""
try: try:
async with get_db() as db: async with get_db() as db:
from sqlalchemy import text from sqlalchemy import bindparam, text
# 保存市场温度 # 保存市场温度
mt = result.get("market_temp") mt = result.get("market_temp")
if mt: if mt:
@ -428,9 +635,13 @@ async def _save_to_db(result: dict):
if qualified_recs: if qualified_recs:
# 批量删除当日同一 ts_code 的旧记录 # 批量删除当日同一 ts_code 的旧记录
codes = [rec.ts_code for rec in qualified_recs] 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( await db.execute(
text("DELETE FROM recommendations WHERE date(created_at) = :today AND ts_code IN :codes"), delete_stmt,
{"today": today_str, "codes": tuple(codes)}, {"today": today_str, "codes": codes},
) )
# 批量插入新记录 # 批量插入新记录
rec_values = [ rec_values = [
@ -452,9 +663,18 @@ async def _save_to_db(result: dict):
"target_price": rec.target_price, "target_price": rec.target_price,
"stop_loss": rec.stop_loss, "stop_loss": rec.stop_loss,
"reasons": json.dumps(rec.reasons, ensure_ascii=False), "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, "llm_analysis": rec.llm_analysis,
"strategy": rec.strategy, "strategy": rec.strategy,
"entry_signal_type": rec.entry_signal_type, "entry_signal_type": rec.entry_signal_type,
"entry_timing": rec.entry_timing,
"llm_score": rec.llm_score, "llm_score": rec.llm_score,
"scan_session": rec.scan_session, "scan_session": rec.scan_session,
"created_at": now_dt, "created_at": now_dt,
@ -481,7 +701,10 @@ async def _load_today_from_db() -> dict:
# 加载市场温度(按 trade_date 取最新交易日) # 加载市场温度(按 trade_date 取最新交易日)
result = await db.execute( 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() mt_row = result.fetchone()
market_temp = None market_temp = None
@ -530,6 +753,15 @@ async def _load_today_from_db() -> dict:
target_price=r["target_price"], target_price=r["target_price"],
stop_loss=r["stop_loss"], stop_loss=r["stop_loss"],
reasons=json.loads(r["reasons"]) if r["reasons"] else [], 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 "", llm_analysis=r.get("llm_analysis") or "",
strategy=r.get("strategy") or "trend_breakout", strategy=r.get("strategy") or "trend_breakout",
entry_signal_type=r.get("entry_signal_type") or "none", entry_signal_type=r.get("entry_signal_type") or "none",
@ -542,6 +774,10 @@ async def _load_today_from_db() -> dict:
"hot_sectors": [], "hot_sectors": [],
"capital_filtered": [], "capital_filtered": [],
"recommendations": recommendations, "recommendations": recommendations,
"strategy_profile": {
"strategy_id": recommendations[0].strategy if recommendations else "trend_breakout",
"name": "当前推荐策略",
} if recommendations else None,
} }
except Exception as e: except Exception as e:
logger.error(f"从数据库加载推荐失败: {e}") logger.error(f"从数据库加载推荐失败: {e}")
@ -558,10 +794,14 @@ async def _load_sectors_from_db() -> list[SectorInfo]:
result = await db.execute( result = await db.execute(
text( text(
"SELECT * FROM sector_heat " "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 (" "AND id IN ("
" SELECT MAX(id) FROM sector_heat " " 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" " GROUP BY sector_code"
") " ") "
"ORDER BY heat_score DESC" "ORDER BY heat_score DESC"

View File

@ -10,6 +10,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from app.engine.recommender import refresh_recommendations from app.engine.recommender import refresh_recommendations
from app.engine.watchlist import analyze_watchlist_for_all_users
from app.api.websocket import broadcast_update from app.api.websocket import broadcast_update
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -54,6 +55,18 @@ async def _generate_daily_review():
await log_error("scheduler", f"复盘报告生成异常: {e}", detail=traceback.format_exc()) 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(): def setup_scheduler():
"""配置所有定时任务(交易日时间)""" """配置所有定时任务(交易日时间)"""
@ -110,6 +123,11 @@ def setup_scheduler():
id="daily_review", replace_existing=True 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("盘中调度器已配置完成") logger.info("盘中调度器已配置完成")

View File

@ -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.analysis.intraday import intraday_market_temperature, intraday_filter_stocks, intraday_sector_scan
from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation
from app.config import settings, is_trading_hours, is_market_session from app.config import settings, is_trading_hours, is_market_session
from app.llm.strategy_selector import select_strategy_profile
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -82,6 +83,12 @@ async def run_screening(trade_date: str = None) -> dict:
if intraday: if intraday:
hot_sectors = await intraday_sector_scan(hot_sectors) 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: 板块内选股 ── # ── Step 2: 板块内选股 ──
logger.info("=== Step 2: 板块内选股 ===") logger.info("=== Step 2: 板块内选股 ===")
if intraday: if intraday:
@ -123,11 +130,11 @@ async def run_screening(trade_date: str = None) -> dict:
# ── Step 3: 供需 + 价格行为 + 趋势评分 ── # ── Step 3: 供需 + 价格行为 + 趋势评分 ──
logger.info("=== Step 3: 深度分析 ===") logger.info("=== Step 3: 深度分析 ===")
recommendations = await _build_recommendations( 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分不推荐 # 过滤低质量推荐低于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}) ===") logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===")
for r in recommendations[:5]: for r in recommendations[:5]:
@ -140,6 +147,7 @@ async def run_screening(trade_date: str = None) -> dict:
"hot_sectors": hot_sectors, "hot_sectors": hot_sectors,
"recommendations": recommendations, "recommendations": recommendations,
"scan_mode": scan_mode, "scan_mode": scan_mode,
"strategy_profile": strategy_profile.model_dump(),
} }
@ -315,6 +323,7 @@ async def _build_recommendations(
hot_sectors: list[SectorInfo], hot_sectors: list[SectorInfo],
market_temp_score: float = 0, market_temp_score: float = 0,
intraday: bool = False, intraday: bool = False,
strategy_profile=None,
) -> list[Recommendation]: ) -> list[Recommendation]:
"""Step 3: 对候选做供需 + 价格行为 + 趋势深度分析 """Step 3: 对候选做供需 + 价格行为 + 趋势深度分析
@ -345,6 +354,13 @@ async def _build_recommendations(
llm_candidates = [] # 收集候选摘要供 LLM 分析 llm_candidates = [] # 收集候选摘要供 LLM 分析
total = len(candidates) total = len(candidates)
signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0} 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): for idx, stock in enumerate(candidates):
ts_code = stock.get("ts_code", "") ts_code = stock.get("ts_code", "")
@ -377,6 +393,9 @@ async def _build_recommendations(
if signal_type == EntrySignal.NONE: if signal_type == EntrySignal.NONE:
signal_counts["none"] += 1 signal_counts["none"] += 1
continue continue
if signal_priority and signal_type.value not in signal_priority[:4]:
signal_counts["none"] += 1
continue
signal_counts[signal_type.value] += 1 signal_counts[signal_type.value] += 1
# ── 三维度评分 ── # ── 三维度评分 ──
@ -400,9 +419,9 @@ async def _build_recommendations(
# 综合评分(短线交易:供需最关键,趋势只做门槛) # 综合评分(短线交易:供需最关键,趋势只做门槛)
final_score = ( final_score = (
supply_demand_score * 0.50 + supply_demand_score * score_weights["supply_demand"] +
price_action_score * 0.40 + price_action_score * score_weights["price_action"] +
trend_score * 0.10 trend_score * score_weights["trend"]
) )
# ── 风险乘数:惩罚取最大而非叠加(避免过度惩罚),奖励可叠加 ── # ── 风险乘数:惩罚取最大而非叠加(避免过度惩罚),奖励可叠加 ──
@ -442,6 +461,15 @@ async def _build_recommendations(
if entry_signal.get("signal_score", 0) >= 80: if entry_signal.get("signal_score", 0) >= 80:
final_score *= 1.10 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") pe = stock.get("pe")
pb = stock.get("pb") pb = stock.get("pb")
@ -454,7 +482,7 @@ async def _build_recommendations(
if (signal_type != EntrySignal.NONE if (signal_type != EntrySignal.NONE
and entry_signal.get("signal_score", 0) >= 50 and entry_signal.get("signal_score", 0) >= 50
and position_score >= 30 and position_score >= 30
and final_score >= 60): and final_score >= buy_threshold):
signal = "BUY" signal = "BUY"
# 价格参考 — 结构化止损止盈(基于市场结构而非固定百分比) # 价格参考 — 结构化止损止盈(基于市场结构而非固定百分比)
@ -547,6 +575,7 @@ async def _build_recommendations(
# 生成推荐理由 # 生成推荐理由
reasons = _generate_reasons(stock, entry_signal, tech_signal, df, intraday) 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) 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) 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( rec = Recommendation(
ts_code=ts_code, ts_code=ts_code,
@ -575,9 +615,16 @@ async def _build_recommendations(
reasons=reasons, reasons=reasons,
risk_note=risk_note, risk_note=risk_note,
level=level, level=level,
strategy="trend_breakout", strategy=strategy_profile.strategy_id if strategy_profile else "trend_breakout",
entry_signal_type=signal_type.value, entry_signal_type=signal_type.value,
entry_timing=entry_timing, 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) recommendations.append(rec)
@ -963,6 +1010,86 @@ def _generate_entry_timing(signal_type: str, intraday: bool) -> str:
return timing_map.get(signal_type, "盘中观察量价配合,确认信号后进场") 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: def _score_to_level(score: float) -> str:
if score >= 80: if score >= 80:
return "强烈推荐" return "强烈推荐"

View 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

View File

@ -11,7 +11,7 @@ from typing import AsyncGenerator
from app.llm.client import chat_completion, stream_chat_completion, get_client from app.llm.client import chat_completion, stream_chat_completion, get_client
from app.llm.prompts import CHAT_SYSTEM_PROMPT from app.llm.prompts import CHAT_SYSTEM_PROMPT
from app.llm.tools import CHAT_TOOLS 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 from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -20,16 +20,18 @@ MAX_TOOL_ROUNDS = 5
# 工具名称映射(用于状态提示) # 工具名称映射(用于状态提示)
TOOL_LABELS = { TOOL_LABELS = {
"get_strategy_board": "读取今日作战结论",
"get_market_temperature": "查询市场温度", "get_market_temperature": "查询市场温度",
"get_hot_sectors": "查询热门板块", "get_hot_sectors": "查询热门板块",
"get_latest_recommendations": "查询推荐列表", "get_latest_recommendations": "查询推荐列表",
"get_user_watchlist_snapshot": "读取自选股作战池",
"get_stock_kline": "查询K线数据", "get_stock_kline": "查询K线数据",
"get_stock_capital_flow": "查询资金流向", "get_stock_capital_flow": "查询资金流向",
"search_stock": "搜索股票", "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 循环 """流式对话,支持 tool use 循环
Yields: 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"} yield {"type": "content", "content": "LLM 未配置,请在 .env 中设置 ASTOCK_DEEPSEEK_API_KEY"}
return return
set_chat_user_context(current_user)
# 构建完整消息列表 # 构建完整消息列表
full_messages = [{"role": "system", "content": CHAT_SYSTEM_PROMPT}] full_messages = [{"role": "system", "content": CHAT_SYSTEM_PROMPT}]
full_messages.extend(messages) full_messages.extend(messages)
# Tool use 循环(非流式,直到没有 tool_calls try:
for round_num in range(MAX_TOOL_ROUNDS): # Tool use 循环(非流式,直到没有 tool_calls
if round_num == 0: for round_num in range(MAX_TOOL_ROUNDS):
yield {"type": "status", "content": "思考中..."} if round_num == 0:
yield {"type": "status", "content": "整理今日作战上下文..."}
resp = await chat_completion(full_messages, tools=CHAT_TOOLS) resp = await chat_completion(full_messages, tools=CHAT_TOOLS)
if not resp: if not resp:
yield {"type": "content", "content": "AI 服务暂时不可用,请稍后重试"} yield {"type": "content", "content": "AI 服务暂时不可用,请稍后重试"}
return return
# 检查是否有 tool_calls # 检查是否有 tool_calls
if not resp.tool_calls: if not resp.tool_calls:
break 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)
# 将 assistant 消息(含 tool_calls加入历史
full_messages.append({ full_messages.append({
"role": "tool", "role": "assistant",
"tool_call_id": tc.id, "content": resp.content or "",
"content": result, "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: for tc in resp.tool_calls:
# 超过最大轮次,用最后的消息生成回复 try:
pass 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": ""} # 清除状态 yield {"type": "status", "content": f"正在{tool_label}..."}
async for delta in stream_chat_completion(full_messages):
if delta.content: logger.info(f"Chat Agent 调用工具: {tc.function.name}({args})")
yield {"type": "content", "content": delta.content} 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)

View File

@ -1,7 +1,6 @@
"""每日复盘报告生成""" """每日复盘报告生成"""
import logging import logging
from datetime import datetime
from app.config import settings from app.config import settings
@ -10,13 +9,9 @@ logger = logging.getLogger(__name__)
async def generate_review() -> dict: 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.tushare_client import tushare_client
from app.data import tencent_client from app.data import tencent_client
from app.engine.recommender import get_latest_recommendations, get_latest_sectors 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() trade_date = tushare_client.get_latest_trade_date()
@ -83,19 +78,43 @@ async def generate_review() -> dict:
## 明日关注 ## 明日关注
关注方向和操作建议""" 关注方向和操作建议"""
try: if settings.deepseek_api_key:
client = get_client() try:
response = await client.chat.completions.create( from app.llm.client import get_client
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()
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 sqlalchemy import text
from app.db.database import get_db from app.db.database import get_db
@ -109,9 +128,40 @@ async def generate_review() -> dict:
) )
await db.commit() await db.commit()
logger.info(f"已生成 {trade_date} 复盘报告") logger.info(f"已生成 {trade_date} 复盘报告 ({generated_by})")
return {"status": "ok", "trade_date": trade_date, "content": content} return {"status": "ok", "trade_date": trade_date, "content": content, "generated_by": generated_by}
except Exception as e: except Exception as e:
logger.error(f"生成复盘报告失败: {e}") logger.error(f"保存复盘报告失败: {e}")
return {"status": "error", "message": str(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}
## 明日关注
优先跟踪重点关注标的能否满足触发条件同时观察主线板块是否延续若市场温度回落或板块资金退潮应降低仓位并把未确认标的转回观察池"""

View File

@ -34,25 +34,29 @@ ENHANCE_USER_TEMPLATE = """\
请对该股票进行 2-3 句话的深度分析""" 请对该股票进行 2-3 句话的深度分析"""
CHAT_SYSTEM_PROMPT = """\ CHAT_SYSTEM_PROMPT = """\
你是一位专业的 A 股投资顾问 AI 助手你可以通过工具查询实时市场数据来回答用户问题 你是 A 股投研作战台里的 AI 作战助理不是泛化闲聊机器人你的核心任务是解释系统已经生成的结果并帮助用户把市场板块推荐和自选股串成可执行判断
你的能力 你的能力
1. 查询市场温度热门板块推荐股票列表 1. 查询今日作战结论包括市场状态今日打法建议仓位重点板块和规避规则
2. 查询个股K线资金流向数据 2. 查询市场温度热门板块推荐股票列表
3. 搜索股票代码 3. 查询当前用户的自选股池与最新建议
4. 基于数据给出专业的市场分析和投资建议 4. 查询个股K线技术面资金流向数据
5. 搜索股票代码并把结果放回当前交易语境中分析
重要提醒 重要提醒
- 回答用户关于"今天市场怎么样"之类的问题时必须调用 get_realtime_indices 获取实时指数数据 - 回答用户关于"今天市场怎么样"之类的问题时必须调用 get_realtime_indices 获取实时指数数据
- 盘中时段9:30-15:00必须使用实时数据盘后时段使用当日收盘数据 - 回答用户关于"今天该怎么做""当前推荐怎么看""自选股该怎么处理"这类问题时优先调用 get_strategy_boardget_latest_recommendationsget_user_watchlist_snapshot
- 不要使用过时的数据必须先调用工具获取最新数据再回答 - 盘中时段9:30-15:00必须使用实时数据盘后时段使用当日收盘或最近一次系统生成的数据
- 不要脱离系统上下文泛泛而谈必须先调用工具获取最新结果再回答
回答要求 回答要求
1. 使用工具获取最新数据后再回答不要凭空编造数据 1. 使用工具获取最新数据后再回答不要凭空编造数据
2. 分析要结合 A 股市场特点资金驱动板块轮动情绪周期 2. 优先把结论组织成当前判断 / 依据 / 下一步观察点 / 风险提示
3. 给出具体建议时要附带风险提示 3. 分析要结合 A 股市场特点资金驱动板块轮动情绪周期
4. 语言简洁专业有条理 4. 如果用户问题过于宽泛主动收敛到系统里的现成模块不要输出空泛宏论
5. 回复使用 markdown 格式适当用列表和加粗提升可读性 5. 给出具体建议时要附带风险提示并明确这是观察建议执行条件还是规避建议
6. 语言简洁专业有条理
7. 回复使用 markdown 格式适当用列表和加粗提升可读性
免责声明你的分析仅供参考不构成投资建议投资有风险入市需谨慎 免责声明你的分析仅供参考不构成投资建议投资有风险入市需谨慎
""" """

View 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 ""

View 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)

View 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

View File

@ -9,16 +9,27 @@ import math
logger = logging.getLogger(__name__) 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: async def execute_tool(name: str, arguments: dict) -> str:
"""执行工具调用,返回 JSON 字符串""" """执行工具调用,返回 JSON 字符串"""
try: 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() return await _get_market_temperature()
elif name == "get_hot_sectors": elif name == "get_hot_sectors":
return await _get_hot_sectors(arguments.get("limit", 10)) return await _get_hot_sectors(arguments.get("limit", 10))
elif name == "get_latest_recommendations": elif name == "get_latest_recommendations":
return await _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": elif name == "get_stock_kline":
return await _get_stock_kline( return await _get_stock_kline(
arguments["ts_code"], arguments.get("days", 60) arguments["ts_code"], arguments.get("days", 60)
@ -53,6 +64,28 @@ def _clean_for_json(obj):
return 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: async def _get_market_temperature() -> str:
from app.engine.recommender import get_latest_recommendations from app.engine.recommender import get_latest_recommendations
result = await 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 from app.engine.recommender import get_latest_recommendations
result = await get_latest_recommendations() result = await get_latest_recommendations()
recs = result.get("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) 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: async def _get_stock_kline(ts_code: str, days: int) -> str:
from app.data.tushare_client import tushare_client from app.data.tushare_client import tushare_client
from app.analysis.technical import add_all_indicators from app.analysis.technical import add_all_indicators

View File

@ -4,6 +4,18 @@
""" """
CHAT_TOOLS = [ CHAT_TOOLS = [
{
"type": "function",
"function": {
"name": "get_strategy_board",
"description": "获取今日作战结论,包括市场状态、今日打法、建议仓位、重点板块和规避规则",
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@ -45,6 +57,18 @@ CHAT_TOOLS = [
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "get_user_watchlist_snapshot",
"description": "获取当前用户自选股概览,包括分组、最新结论、建议、触发条件和摘要",
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {

View File

@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.db.database import init_db from app.db.database import init_db
from app.engine.scheduler import start_scheduler, stop_scheduler 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( logging.basicConfig(
level=logging.DEBUG if settings.debug else logging.INFO, 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(sectors.router)
app.include_router(recommendations.router) app.include_router(recommendations.router)
app.include_router(stocks.router) app.include_router(stocks.router)
app.include_router(watchlists.router)
app.include_router(chat.router) app.include_router(chat.router)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(debug.router) app.include_router(debug.router)

Binary file not shown.

View File

@ -1,5 +1,11 @@
{ {
"pages": { "pages": {
"/layout": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/css/app/layout.css",
"static/chunks/app/layout.js"
],
"/(auth)/dashboard/page": [ "/(auth)/dashboard/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
@ -10,22 +16,61 @@
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/(auth)/layout.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": [ "/(auth)/recommendations/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/(auth)/recommendations/page.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": [ "/(auth)/settings/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/(auth)/settings/page.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": [ "/_not-found/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",

View File

@ -2,7 +2,9 @@
"polyfillFiles": [ "polyfillFiles": [
"static/chunks/polyfills.js" "static/chunks/polyfills.js"
], ],
"devFiles": [], "devFiles": [
"static/chunks/react-refresh.js"
],
"ampDevFiles": [], "ampDevFiles": [],
"lowPriorityFiles": [ "lowPriorityFiles": [
"static/development/_buildManifest.js", "static/development/_buildManifest.js",
@ -13,7 +15,16 @@
"static/chunks/main-app.js" "static/chunks/main-app.js"
], ],
"pages": { "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": [] "ampFirstPages": []
} }

View File

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

View File

@ -1,6 +1,8 @@
{ {
"/_not-found/page": "app/_not-found/page.js", "/_not-found/page": "app/_not-found/page.js",
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js", "/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js", "/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js",
"/(auth)/settings/page": "app/(auth)/settings/page.js" "/(auth)/chat/page": "app/(auth)/chat/page.js",
"/(public)/login/page": "app/(public)/login/page.js",
"/(public)/page": "app/(public)/page.js"
} }

View File

@ -1 +1 @@
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"; self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"

View File

@ -2,7 +2,9 @@ self.__BUILD_MANIFEST = {
"polyfillFiles": [ "polyfillFiles": [
"static/chunks/polyfills.js" "static/chunks/polyfills.js"
], ],
"devFiles": [], "devFiles": [
"static/chunks/react-refresh.js"
],
"ampDevFiles": [], "ampDevFiles": [],
"lowPriorityFiles": [], "lowPriorityFiles": [],
"rootMainFiles": [ "rootMainFiles": [
@ -10,7 +12,16 @@ self.__BUILD_MANIFEST = {
"static/chunks/main-app.js" "static/chunks/main-app.js"
], ],
"pages": { "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": [] "ampFirstPages": []
}; };

View File

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

View File

@ -1 +1,5 @@
{} {
"/_app": "pages/_app.js",
"/_error": "pages/_error.js",
"/_document": "pages/_document.js"
}

View File

@ -1,5 +1,5 @@
{ {
"node": {}, "node": {},
"edge": {}, "edge": {},
"encryptionKey": "f4eykmt9lLjeIDNHjaA0ZKJupk05dXT0k2cBaExPwP8=" "encryptionKey": "5a77t1jXySke+j0Es8vduY/7S7yObSbYfKeh0OReITs="
} }

View File

@ -1,9 +1,9 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import { useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { streamChat, type ChatMessage } from "@/lib/api";
import { formatMarkdown } from "@/lib/markdown"; import { formatMarkdown } from "@/lib/markdown";
import { streamChat, type ChatMessage } from "@/lib/api";
interface DisplayMessage { interface DisplayMessage {
role: "user" | "assistant"; role: "user" | "assistant";
@ -11,9 +11,25 @@ interface DisplayMessage {
} }
const QUICK_QUESTIONS = [ const QUICK_QUESTIONS = [
"今日市场怎么样?", "结合今日作战结论,告诉我今天应该重点看什么。",
"有哪些推荐股票?", "把当前推荐池分成可操作、重点关注和仅观察三层讲给我。",
"哪些板块最热门?", "看看我的自选股里哪些需要明天优先盯盘。",
"如果今天只允许做一个方向,你建议我盯哪个主线,为什么?",
];
const CHAT_SCENES = [
{
title: "问今日打法",
description: "把今日结论翻译成人话,说明现在该进攻、试错还是防守。",
},
{
title: "问推荐池",
description: "追问某只推荐股为什么进池、什么条件下能看、什么条件下放弃。",
},
{
title: "问自选股",
description: "围绕你自己的观察池、候选池和持仓池做连续追问。",
},
]; ];
export default function ChatPage() { export default function ChatPage() {
@ -37,42 +53,41 @@ export default function ChatPage() {
}, [messages, status]); }, [messages, status]);
const sendMessage = async (text: string) => { 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]; const newMessages = [...messages, userMsg];
setMessages(newMessages);
setMessages([...newMessages, { role: "assistant", content: "" }]);
setInput(""); setInput("");
setStreaming(true); setStreaming(true);
setStatus(""); setStatus("");
// Add empty assistant message for streaming
setMessages([...newMessages, { role: "assistant", content: "" }]);
try { try {
const chatMessages: ChatMessage[] = newMessages.map((m) => ({ const chatMessages: ChatMessage[] = newMessages.map((message) => ({
role: m.role, role: message.role,
content: m.content, content: message.content,
})); }));
let fullContent = ""; let fullContent = "";
for await (const event of streamChat(chatMessages)) { for await (const event of streamChat(chatMessages)) {
if (event.type === "status") { if (event.type === "status") {
setStatus(event.content); setStatus(event.content);
} else if (event.type === "content") { continue;
fullContent += event.content;
setMessages([
...newMessages,
{ role: "assistant", content: fullContent },
]);
setStatus("");
} }
fullContent += event.content;
setMessages([
...newMessages,
{ role: "assistant", content: fullContent },
]);
} }
} catch (e) { } catch (error) {
console.error("Chat error:", e); console.error("Chat error:", error);
setMessages([ setMessages([
...newMessages, ...newMessages,
{ role: "assistant", content: "连接失败,请检查网络后重试。" }, { role: "assistant", content: "连接失败,暂时无法读取作战数据,请稍后重试。" },
]); ]);
} finally { } finally {
setStreaming(false); setStreaming(false);
@ -80,140 +95,177 @@ export default function ChatPage() {
} }
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
e.preventDefault(); event.preventDefault();
sendMessage(input); sendMessage(input);
} }
}; };
return ( return (
<div className="max-w-3xl mx-auto flex flex-col h-[100dvh] pb-20 md:pb-10"> <div className="mx-auto flex h-[100dvh] max-w-7xl flex-col px-4 pb-20 pt-6 md:px-8 md:pb-10">
{/* Header */} <div className="grid min-h-0 flex-1 gap-5 xl:grid-cols-[320px_minmax(0,1fr)]">
<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"> <aside className="hidden xl:flex xl:flex-col xl:gap-4">
<div className="flex items-center gap-3"> <div className="glass-card-static p-5 animate-fade-in-up">
<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"> <div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-cyan-400">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-accent-cyan/70"> Combat Chat
<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> </div>
<h2 className="text-sm font-semibold mb-1.5"></h2> <h1 className="mt-2 text-xl font-bold tracking-tight">AI </h1>
<p className="text-xs text-text-muted mb-8 max-w-[240px] leading-relaxed"> <p className="mt-2 text-sm leading-7 text-text-secondary">
</p> </p>
<div className="flex flex-col gap-2 w-full max-w-[280px]"> </div>
{QUICK_QUESTIONS.map((q) => (
<button <div className="glass-card-static p-5 animate-fade-in-up">
key={q} <div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">
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" </div>
> <div className="mt-4 space-y-3">
{q} {CHAT_SCENES.map((scene) => (
</button> <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>
</div> </div>
) : (
<> <div className="glass-card-static p-5 animate-fade-in-up">
{messages.map((msg, i) => ( <div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-text-muted">
<div 使
key={i} </div>
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"} animate-fade-in-up`} <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 ${ </button>
msg.role === "user" ) : null}
? "bg-gradient-to-r from-orange-500/20 to-amber-500/15 text-orange-100 border border-orange-500/10" </div>
: "glass-card-static"
}`} <div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5">
> {messages.length === 0 ? (
{msg.role === "assistant" ? ( <div className="flex h-full flex-col justify-center">
msg.content ? ( <div className="mx-auto max-w-2xl text-center animate-fade-in-up">
<div <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">
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"}`} <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-cyan-300/60">
dangerouslySetInnerHTML={{ <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" />
__html: formatMarkdown(msg.content), </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">
<span className="text-text-muted/50 text-xs"> AI
{status || "思考中..."} </p>
</span> </div>
)
) : ( <div className="mx-auto mt-8 grid w-full max-w-3xl gap-3 md:grid-cols-2">
<span>{msg.content}</span> {QUICK_QUESTIONS.map((question) => (
)} <button
{streaming && i === messages.length - 1 && msg.role === "assistant" && msg.content && ( key={question}
<span className="inline-block w-1.5 h-4 bg-accent-cyan/60 ml-0.5 animate-pulse rounded-full" /> 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>
</div> </div>
))} ) : (
{/* Status indicator during tool calls */} <div className="space-y-4">
{streaming && status && messages[messages.length - 1]?.content && ( {messages.map((message, index) => (
<div className="flex justify-start"> <div
<div className="text-xs text-accent-cyan/50 flex items-center gap-2 px-3"> key={`${message.role}-${index}`}
<span className="inline-block w-2.5 h-2.5 border border-accent-cyan/30 border-t-accent-cyan/70 rounded-full animate-spin" /> className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} animate-fade-in-up`}
{status} >
</div> <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>
)}
</div>
{/* Input */} <div className="border-t border-border-subtle bg-bg-primary/40 px-4 py-3 sm:px-5">
<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">
<div className="flex items-end gap-2"> <textarea
<textarea ref={inputRef}
ref={inputRef} value={input}
value={input} onChange={(event) => setInput(event.target.value)}
onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown}
onKeyDown={handleKeyDown} placeholder="比如:结合我的自选股和今日主线,明天我应该先看哪几只?"
placeholder="输入问题..." rows={1}
rows={1} disabled={streaming}
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" 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"
disabled={streaming} />
/> <button
<button onClick={() => sendMessage(input)}
onClick={() => sendMessage(input)} disabled={!input.trim() || streaming}
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"
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>
</button> </div>
</div> <div className="mt-2 text-center text-xs text-text-muted/35">
<div className="text-xs text-text-muted/30 text-center mt-2">
AI </div>
</div> </div>
</section>
</div> </div>
</div> </div>
); );
} }

View File

@ -2,14 +2,12 @@
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { fetchAPI, postAPI } from "@/lib/api"; 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 MarketTemp from "@/components/market-temp";
import StockCard from "@/components/stock-card";
import SectorHeatmap from "@/components/sector-heatmap"; import SectorHeatmap from "@/components/sector-heatmap";
import { useWebSocket } from "@/hooks/use-websocket"; import { useWebSocket } from "@/hooks/use-websocket";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { markdownToHtml } from "@/lib/markdown";
interface ScanStatus { interface ScanStatus {
is_trading: boolean; is_trading: boolean;
@ -27,30 +25,28 @@ export default function DashboardPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [refreshResult, setRefreshResult] = useState<string | null>(null); const [refreshResult, setRefreshResult] = useState<string | null>(null);
const [llmEnabled, setLlmEnabled] = useState(false);
const [indices, setIndices] = useState<IndexOverview[]>([]); const [indices, setIndices] = useState<IndexOverview[]>([]);
const [dailyReview, setDailyReview] = useState<string | null>(null); const [strategyBoard, setStrategyBoard] = useState<StrategyBoard | null>(null);
const [generatingReview, setGeneratingReview] = useState(false); const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
const [opsRunning, setOpsRunning] = useState<string | null>(null);
const scanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const scanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { 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<LatestResult>("/api/recommendations/latest"),
fetchAPI<SectorData[]>("/api/sectors/hot?limit=8"), fetchAPI<SectorData[]>("/api/sectors/hot?limit=8"),
fetchAPI<ScanStatus>("/api/recommendations/status"), fetchAPI<ScanStatus>("/api/recommendations/status"),
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
fetchAPI<IndexOverview[]>("/api/market/overview").catch(() => []), 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); setData(latest);
setSectors(sectorData); setSectors(sectorData);
setScanStatus(status); setScanStatus(status);
setLlmEnabled(health.llm_enabled);
setIndices(overview); setIndices(overview);
if (reviewData.reviews?.length > 0) { setStrategyBoard(board);
setDailyReview(reviewData.reviews[0].content); setOpsStatus(ops);
}
} catch (e) { } catch (e) {
console.error("加载数据失败:", e); console.error("加载数据失败:", e);
} finally { } 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(() => { useEffect(() => {
return () => clearScanTimeout(); return () => clearScanTimeout();
@ -144,7 +165,7 @@ export default function DashboardPage() {
{/* Header bar */} {/* Header bar */}
<div className="flex items-center justify-between animate-fade-in-up"> <div className="flex items-center justify-between animate-fade-in-up">
<div> <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 && ( {scanStatus && (
<p className="text-xs text-text-muted mt-1"> <p className="text-xs text-text-muted mt-1">
{scanStatus.is_trading ? ( {scanStatus.is_trading ? (
@ -196,102 +217,290 @@ export default function DashboardPage() {
</div> </div>
)} )}
{/* Market temp + Sector heatmap */} <MissionControl
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> board={strategyBoard}
<MarketTemp data={data?.market_temperature ?? null} indices={indices} /> recommendations={data?.recommendations ?? []}
<SectorHeatmap sectors={sectors} /> sectors={sectors}
</div> 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="animate-fade-in-up delay-100">
<div className="flex items-center justify-between mb-3"> <EvidenceHeader title="决策证据" />
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
<MarketTemp data={data?.market_temperature ?? null} indices={indices} />
</h2> <SectorHeatmap sectors={sectors} />
{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>
)}
</div> </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> </div>
{/* Recommendations */} </div>
<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">
function OpsPanel({
{data?.recommendations?.length ? ( opsStatus,
<span className="text-text-primary ml-1.5 font-mono tabular-nums">{data.recommendations.length}</span> isAdmin,
) : ""} refreshing,
</h2> onRefresh,
<a href="/recommendations" className="text-xs text-text-muted hover:text-amber-400 transition-colors flex items-center gap-1"> opsRunning,
onAction,
<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" /> opsStatus: OpsStatusResponse;
</svg> 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> </a>
</div> </div>
) : null}
</div>
);
}
{!data?.recommendations?.length ? ( function EvidenceHeader({ title }: { title: string }) {
<div className="glass-card-static p-10 text-center"> return (
<div className="text-text-muted text-sm mb-1"></div> <div>
<div className="text-text-muted/60 text-xs"> <h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{user?.role === "admin" ? `点击 ${scanStatus?.is_trading ? "「盘中扫描」" : "「立即扫描」"} 开始分析` : "等待系统自动扫描更新"} {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> </div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-2">
{data.recommendations.slice(0, 6).map((rec) => ( <CompactDecision label="今日打法" value={strategyName} extra={strategyHint} />
<StockCard key={rec.ts_code} rec={rec} showLLMLoading={llmEnabled} /> <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>
)}
<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>
</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>
);
}

View File

@ -3,7 +3,7 @@
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useSearchParams } from "next/navigation"; 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 { markdownToHtml } from "@/lib/markdown";
import { ErrorBoundary } from "@/components/error-boundary"; import { ErrorBoundary } from "@/components/error-boundary";
@ -13,6 +13,14 @@ interface SearchResult {
industry: string; industry: string;
} }
interface DiagnoseHistoryItem {
ts_code: string;
name: string;
diagnosis_mode?: string;
diagnosis?: string;
created_at?: string;
}
export default function DiagnosePage() { export default function DiagnosePage() {
const { theme } = useTheme(); const { theme } = useTheme();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -25,6 +33,9 @@ export default function DiagnosePage() {
const [result, setResult] = useState<DiagnosisResult | null>(null); const [result, setResult] = useState<DiagnosisResult | null>(null);
const [cachedResult, setCachedResult] = useState<string | null>(null); const [cachedResult, setCachedResult] = useState<string | null>(null);
const [history, setHistory] = useState<{ ts_code: string; name: string }[]>([]); 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 inputRef = useRef<HTMLInputElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout>>(); const searchTimer = useRef<ReturnType<typeof setTimeout>>();
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
@ -92,12 +103,15 @@ export default function DiagnosePage() {
setResult(null); setResult(null);
setCachedResult(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 { try {
const token = localStorage.getItem("auth_token"); const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; 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", method: "POST",
headers, headers,
}); });
@ -166,7 +180,7 @@ export default function DiagnosePage() {
return ( return (
<ErrorBoundary> <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 */} {/* Header */}
<div className="mb-6 animate-fade-in-up"> <div className="mb-6 animate-fade-in-up">
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-3 mb-1">
@ -178,179 +192,317 @@ export default function DiagnosePage() {
</div> </div>
<div> <div>
<h1 className="text-lg font-bold tracking-tight">AI </h1> <h1 className="text-lg font-bold tracking-tight">AI </h1>
<p className="text-xs text-text-muted">
AI
</p>
</div> </div>
</div> </div>
</div> </div>
{/* Search Input */} <div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_320px] gap-5">
<div ref={wrapperRef} className="relative z-30 mb-6 animate-fade-in-up"> <section className="space-y-5">
<div className="flex gap-2"> <div className="glass-card-static p-4 mb-1 animate-fade-in-up">
<div className="relative flex-1"> <div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold mb-2"></div>
<input <div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_auto] gap-3">
ref={inputRef} <div>
type="text" <div className="flex flex-wrap gap-2 mb-3">
value={input} {[
onChange={(e) => handleInputChange(e.target.value)} { key: "entry", label: "建仓前诊断" },
onKeyDown={handleKeyDown} { key: "holding", label: "持仓复核" },
onFocus={() => searchResults.length > 0 && setShowSearch(true)} { key: "review", label: "回撤复盘" },
placeholder="输入股票名称或代码,如 600683 或 京投发展" { key: "tracking", label: "继续跟踪" },
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" ].map((item) => (
disabled={loading} <button
/> key={item.key}
</div> onClick={() => setDiagnoseMode(item.key as typeof diagnoseMode)}
<button className={`text-xs px-3 py-1.5 rounded-lg transition-all ${
onClick={() => runDiagnosis()} diagnoseMode === item.key
disabled={loading || !input.trim()} ? "bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/15"
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" : "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
> }`}
{loading ? ( >
<span className="inline-flex items-center gap-1.5"> {item.label}
<span className="w-3.5 h-3.5 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" /> </button>
))}
</span> </div>
) : ( <div ref={wrapperRef} className="relative z-30">
"开始诊断" <div className="flex gap-2">
)} <div className="relative flex-1">
</button> <input
</div> ref={inputRef}
{showSearch && searchResults.length > 0 && ( type="text"
<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"> value={input}
{searchResults.map((stock) => ( onChange={(e) => handleInputChange(e.target.value)}
<button onKeyDown={handleKeyDown}
key={stock.ts_code} onFocus={() => searchResults.length > 0 && setShowSearch(true)}
onClick={() => selectStock(stock)} placeholder="输入股票名称或代码,如 600683 或 京投发展"
className="w-full flex items-center justify-between px-4 py-2.5 text-sm hover:bg-surface-3 transition-colors text-left" 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 className="flex items-center gap-2"> />
<span className="text-text-primary font-medium">{stock.name}</span> </div>
<span className="text-text-muted text-xs">{stock.ts_code}</span> <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> </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> </div>
<button
onClick={() => runDiagnosis(result?.ts_code || codeParam || undefined)} {thesis ? (
className="text-xs text-text-muted hover:text-amber-400 transition-colors flex items-center gap-1" <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>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <div className="text-sm text-text-secondary mt-2 leading-relaxed">
<path d="M1 4v6h6" /> {thesis.data_freshness.message}
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" /> </div>
</svg> <div className="grid grid-cols-1 gap-2 mt-3">
{(thesis.decision_points ?? []).slice(0, 3).map((point) => (
</button> <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>
<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>
</div>
)}
{/* Error */} {/* Streaming / Loading State */}
{result?.status === "error" && !loading && !displayContent && ( {loading && !displayContent && (
<div className="glass-card-static p-8 text-center animate-fade-in-up"> <div className="glass-card-static p-10 text-center animate-fade-in-up">
<div className="text-sm text-red-400 mb-2"></div> <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-xs text-text-muted">{result.message || "未知错误"}</div> <div className="text-sm text-text-secondary mb-1">...</div>
</div> <div className="text-xs text-text-muted/50"></div>
)} </div>
)}
{/* Empty state */} {/* Streaming content */}
{!result && !loading && !displayContent && history.length === 0 && ( {loading && displayContent && (
<div className="glass-card-static p-10 text-center animate-fade-in-up"> <div className="glass-card-static p-5 animate-fade-in-up">
<div className="w-12 h-12 rounded-2xl bg-surface-2 flex items-center justify-center mx-auto mb-4"> <div className="flex items-center gap-2 mb-3">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-text-muted/40"> <span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
<path d="M9 11l3 3L22 4" /> <span className="text-xs text-text-muted">AI ...</span>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" /> </div>
</svg> <div className="text-sm text-text-secondary leading-relaxed whitespace-pre-line">
</div> {displayContent}
<div className="text-sm text-text-muted mb-2"> AI </div> </div>
<div className="text-xs text-text-muted/50 mb-4"> </div>
600683 )}
</div>
<div className="flex flex-wrap justify-center gap-2"> {/* Final Result */}
{["贵州茅台", "宁德时代", "比亚迪"].map((name) => ( {!loading && displayContent && (
<button <div className="animate-fade-in-up space-y-4">
key={name} <div className="glass-card-static p-4">
onClick={() => { <div className="flex flex-wrap items-center justify-between gap-3 mb-3">
setInput(name); <div>
searchStock(name); <div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold mb-1"></div>
}} <div className="text-sm text-text-secondary"></div>
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" </div>
> <button
{name} onClick={() => runDiagnosis(result?.ts_code || codeParam || undefined)}
</button> className="text-xs text-text-muted hover:text-amber-400 transition-colors flex items-center gap-1"
))} >
</div> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
</div> <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> </div>
</ErrorBoundary> </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 "当前没有明确风险备注";
}

View File

@ -17,7 +17,7 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
</div> </div>
<div> <div>
<h1 className="text-sm font-semibold tracking-tight">Dragon AI Agent</h1> <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> </div>
</div> </div>
@ -44,4 +44,4 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
<MobileBottomNav /> <MobileBottomNav />
</AuthGuard> </AuthGuard>
); );
} }

View File

@ -2,7 +2,7 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { fetchAPI } from "@/lib/api"; 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"; import StockCard from "@/components/stock-card";
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
@ -18,20 +18,46 @@ function formatDate(dateStr: string): string {
return `${d.getMonth() + 1}${d.getDate()}${weekDays[d.getDay()]}`; 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() { export default function RecommendationsPage() {
const [dayGroups, setDayGroups] = useState<DayGroup[]>([]); const [dayGroups, setDayGroups] = useState<DayGroup[]>([]);
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set()); const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
const [filter, setFilter] = useState<string>("all"); const [filter, setFilter] = useState<string>("all");
const [activeFunnel, setActiveFunnel] = useState<string>("actionable");
const [performance, setPerformance] = useState<PerformanceStats | null>(null); 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 () => { const loadData = useCallback(async () => {
try { try {
const [history, perf] = await Promise.all([ const [history, perf, iterationReport, ops] = await Promise.all([
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"), fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null), fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
fetchAPI<StrategyIterationReport>("/api/market/strategy-iteration?limit=50").catch(() => null),
fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null),
]); ]);
setDayGroups(history); setDayGroups(history);
setPerformance(perf); setPerformance(perf);
setIteration(iterationReport);
setOpsStatus(ops);
// 默认展开最近一天 // 默认展开最近一天
setExpandedDays((prev) => { setExpandedDays((prev) => {
@ -68,6 +94,51 @@ export default function RecommendationsPage() {
return recs.filter((r) => r.level === filter); 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; const todayCount = dayGroups.length > 0 ? applyFilter(dayGroups[0].recommendations).length : 0;
// 累计总数 // 累计总数
@ -78,15 +149,52 @@ export default function RecommendationsPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5 animate-fade-in-up"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5 animate-fade-in-up">
<div> <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"> <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">{totalCount}</span> ·
<span className="font-mono tabular-nums ml-1">{dayGroups.length}</span> <span className="font-mono tabular-nums ml-1">{dayGroups.length}</span>
</p> </p>
</div> </div>
</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 Stats */}
{performance && performance.total_recommendations > 0 && ( {performance && performance.total_recommendations > 0 && (
<div className="glass-card-static p-4 mb-5 animate-fade-in-up"> <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>
</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-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> <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>
</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 */} {/* 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"> <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: "全部" }, { 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"> <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="font-mono tabular-nums">{filter === "all" ? group.count : filtered.length}</span>
<span className="text-text-muted/40">·</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 && ( {group.buy_count > 0 && (
<> <>
<span className="text-text-muted/40">·</span> <span className="text-text-muted/40">·</span>
@ -214,7 +347,7 @@ export default function RecommendationsPage() {
{/* Desktop stats */} {/* Desktop stats */}
<div className="hidden sm:flex items-center gap-4 text-xs text-text-muted shrink-0"> <div className="hidden sm:flex items-center gap-4 text-xs text-text-muted shrink-0">
<div className="flex items-center gap-1.5"> <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"> <span className="font-mono tabular-nums font-semibold text-text-secondary">
{group.avg_score} {group.avg_score}
</span> </span>
@ -257,3 +390,210 @@ export default function RecommendationsPage() {
</div> </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";
}

View File

@ -24,10 +24,10 @@ function getStageInfo(stage: string) {
function getOpportunityHint(stage: string, mainForceRatio?: number): string { function getOpportunityHint(stage: string, mainForceRatio?: number): string {
switch (stage) { switch (stage) {
case "early": return "新行情启动,关注领涨股入场时机"; case "early": return "关注启动强度";
case "mid": return (mainForceRatio ?? 0) > 30 ? "行情加速中,主力资金持续流入" : "行情发展中,关注资金动向"; case "mid": return (mainForceRatio ?? 0) > 30 ? "关注回流确认" : "观察资金动向";
case "late": return "行情接近高位,注意获利回吐风险"; case "late": return "注意高位分化";
case "end": return "行情衰退,建议谨慎观望"; case "end": return "以观望为主";
default: 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 className="text-xs font-semibold text-amber-400 uppercase tracking-wider mb-3">
</h2> </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) => { {top3.map((sector, i) => {
const displayPct = sector.realtime_pct_change ?? sector.pct_change; const displayPct = sector.realtime_pct_change ?? sector.pct_change;
const isUp = displayPct > 0; const isUp = displayPct > 0;
@ -272,16 +272,18 @@ function FocusSummary({ sectors }: { sectors: SectorData[] }) {
return ( return (
<div key={sector.sector_code} className={`rounded-xl p-3 border ${stage.bg}`}> <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 flex-wrap items-start justify-between gap-2 mb-1.5">
<div className="flex items-center gap-2"> <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 ${ <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 === 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} {i + 1}
</span> </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>
<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}`}> <span className={`text-[10px] px-1.5 py-0.5 rounded-md border font-medium ${stage.bg} ${stage.color}`}>
{stage.label} {stage.label}
</span> </span>
@ -293,7 +295,7 @@ function FocusSummary({ sectors }: { sectors: SectorData[] }) {
<div className={`text-[11px] ${stage.color} mb-2`}> <div className={`text-[11px] ${stage.color} mb-2`}>
{hint} {hint}
</div> </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"}`}> <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)} {sector.capital_inflow > 0 ? "+" : ""}{formatNumber(sector.capital_inflow)}
</span> </span>
@ -389,93 +391,121 @@ export default function SectorsPage() {
return sectors.filter(s => s.stage === stageFilter); return sectors.filter(s => s.stage === stageFilter);
}, [sectors, 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 ( return (
<ErrorBoundary> <ErrorBoundary>
<div className="max-w-7xl 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">
<div className="flex items-center justify-between mb-5 animate-fade-in-up"> <div className="animate-fade-in-up mb-5">
<div> <h1 className="text-lg font-bold tracking-tight">线</h1>
<h1 className="text-lg font-bold tracking-tight"></h1> <p className="text-xs text-text-muted mt-0.5">
<p className="text-xs text-text-muted mt-0.5"> 线
{hasRealtime && <span className="text-emerald-400/60 ml-1">· </span>}
{hasRealtime && <span className="text-emerald-400/60 ml-1">· </span>} </p>
</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> </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 ? ( {!sectors.length ? (
<div className="glass-card-static p-12 text-center animate-fade-in-up"> <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 text-sm mb-1"></div>
<div className="text-text-muted/50 text-xs"></div> <div className="text-text-muted/50 text-xs"></div>
</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"> <div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.35fr)_340px] gap-5">
{filteredSectors.map((sector) => { <section className="space-y-4">
const originalIndex = sectors.findIndex(s => s.sector_code === sector.sector_code); <MainlineCommandDeck
return ( mainline={sectorBuckets.mainline}
<SectorDetailCard secondary={sectorBuckets.secondary}
key={sector.sector_code} watchlist={sectorBuckets.watchlist}
sector={sector} />
index={originalIndex}
factorScores={factorScoresMap.get(sector.sector_code)} <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>
)} )}
</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 }) { function SectorRotationChart({ data }: { data: SectorRotationData }) {
const [el, setEl] = useState<HTMLDivElement | null>(null); const [el, setEl] = useState<HTMLDivElement | null>(null);
const { theme } = useNextTheme(); const { theme } = useNextTheme();
@ -582,4 +749,4 @@ function useNextTheme() {
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
const { useTheme } = require("next-themes"); const { useTheme } = require("next-themes");
return useTheme(); return useTheme();
} }

View File

@ -3,8 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { fetchAPI } from "@/lib/api"; import { fetchAPI } from "@/lib/api";
import type { RecommendationData, DayGroup } from "@/lib/api"; import type { DayGroup, RecommendationData, StockThesisResponse } from "@/lib/api";
import { getScoreColor } from "@/lib/utils";
import KlineChart from "@/components/kline-chart"; import KlineChart from "@/components/kline-chart";
import CapitalFlowChart from "@/components/capital-flow"; import CapitalFlowChart from "@/components/capital-flow";
import { ErrorBoundary } from "@/components/error-boundary"; import { ErrorBoundary } from "@/components/error-boundary";
@ -31,14 +30,6 @@ interface StockSignals {
position_score: number; position_score: number;
} }
interface RecScore {
supply_demand_score: number;
price_action_score: number;
technical_score: number;
position_score: number;
score: number;
}
interface QuoteData { interface QuoteData {
ts_code: string; ts_code: string;
name: string; name: string;
@ -71,378 +62,398 @@ interface FlowRecord {
sm_net: number; 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() { export default function StockDetailPage() {
const params = useParams(); const params = useParams();
const code = params.code as string; const code = params.code as string;
const [thesis, setThesis] = useState<StockThesisResponse | null>(null);
const [quote, setQuote] = useState<QuoteData | null>(null); const [quote, setQuote] = useState<QuoteData | null>(null);
const [signals, setSignals] = useState<StockSignals | null>(null); const [signals, setSignals] = useState<StockSignals | null>(null);
const [recScore, setRecScore] = useState<RecScore | null>(null); const [recScore, setRecScore] = useState<RecScore | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any const [kline, setKline] = useState<unknown[]>([]);
const [kline, setKline] = useState<any[]>([]);
const [capitalFlow, setCapitalFlow] = useState<FlowRecord[]>([]); const [capitalFlow, setCapitalFlow] = useState<FlowRecord[]>([]);
const [loading, setLoading] = useState(true);
const [evidenceLoading, setEvidenceLoading] = useState(false);
const [evidenceLoaded, setEvidenceLoaded] = useState(false);
useEffect(() => { useEffect(() => {
if (!code) return; if (!code) return;
Promise.all([
fetchAPI<QuoteData>(`/api/stocks/${code}/quote`).catch(() => null), let cancelled = false;
fetchAPI<StockSignals>(`/api/stocks/${code}/signals`).catch(() => null),
fetchAPI<unknown[]>(`/api/stocks/${code}/kline?days=120`).catch(() => []), async function load() {
fetchAPI<FlowRecord[]>(`/api/stocks/${code}/capital_flow?days=10`).catch(() => []), setLoading(true);
]).then(([q, s, k, c]) => { try {
setQuote(q); const [thesisData, history] = await Promise.all([
setSignals(s); fetchAPI<StockThesisResponse>(`/api/stocks/${code}/thesis`).catch(() => null),
setKline(k); fetchAPI<DayGroup[]>(`/api/recommendations/history?days=14`).catch(() => []),
setCapitalFlow(c as FlowRecord[]); ]);
});
// 尝试从推荐历史中获取该股票的评分 if (cancelled) return;
fetchAPI<DayGroup[]>(`/api/recommendations/history?days=14`).then((history) => {
for (const group of history) { setThesis(thesisData);
const rec = group.recommendations?.find((r) => r.ts_code === code);
if (rec && rec.supply_demand_score) { const recFromHistory = history
.flatMap((group) => group.recommendations)
.find((rec) => rec.ts_code === code);
const rec = thesisData?.recommendation ?? recFromHistory ?? null;
if (rec) {
setRecScore({ setRecScore({
supply_demand_score: rec.supply_demand_score, supply_demand_score: rec.supply_demand_score ?? 0,
price_action_score: rec.price_action_score ?? 0, price_action_score: rec.price_action_score ?? 0,
technical_score: rec.technical_score, technical_score: rec.technical_score,
position_score: rec.position_score ?? 50, position_score: rec.position_score ?? 50,
score: rec.score, 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]); }, [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 latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null;
const pageName = recommendation?.name || thesis?.name || quote?.name || code;
return ( return (
<ErrorBoundary> <ErrorBoundary>
<div className="max-w-6xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-4"> <div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
{/* Back */} <a
<a href="/recommendations"
href="/" className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-primary transition-colors animate-fade-in-up"
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"
> >
<path d="M19 12H5M12 19l-7-7 7-7" /> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
</svg> <path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</a>
</a>
{/* Quote header */} <div className="glass-card-static p-5 animate-fade-in-up overflow-hidden relative">
<div className="animate-fade-in-up"> <div className="absolute right-[-90px] top-[-120px] w-72 h-72 rounded-full bg-cyan-500/[0.04] blur-3xl pointer-events-none" />
{quote && ( <div className="relative grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-5">
<div className="glass-card-static p-5"> <div className="min-w-0">
<div className="flex items-center justify-between mb-3"> <div className="flex flex-wrap items-center gap-2 mb-2">
<div className="flex items-center gap-3"> <span className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold">Stock Thesis</span>
<span className="text-lg font-bold tracking-tight">{quote.name}</span> {recommendation?.action_plan ? (
<span className="text-sm text-text-muted font-mono tabular-nums">{quote.ts_code}</span> <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> </div>
<a <a
href={`/diagnose?code=${code}`} 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 AI
</a> </a>
</div> </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>
</div> </div>
)}
{/* K-line + Capital flow chart */} {loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in-up delay-150"> <div className="glass-card-static p-8 text-center text-sm text-text-muted">...</div>
{kline.length > 0 && <KlineChart data={kline} />} ) : (
{capitalFlow.length > 0 && <CapitalFlowChart data={capitalFlow} />} <>
<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>
</div>
</ErrorBoundary> </ErrorBoundary>
); );
} }
/* ── Helper components ── */ function PlanCard({
recommendation,
function MiniStat({ trackingNote,
label,
value,
color,
highlight,
}: { }: {
label: string; recommendation: RecommendationData | null | undefined;
value: string; trackingNote: string;
color?: string;
highlight?: boolean;
}) { }) {
return ( 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="glass-card-static p-5">
<div className="text-[11px] text-text-muted leading-tight">{label}</div> <div className="flex items-center justify-between gap-3 mb-3">
<div className={`text-sm font-bold font-mono tabular-nums mt-0.5 ${color ?? ""}`}> <SectionTitle title="操作计划" />
{value} {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>
</div> </div>
); );
} }
function PositionBar({ label, value, invert = false }: { label: string; value: number; invert?: boolean }) { function EvidenceCard({ recommendation }: { recommendation: RecommendationData | null | undefined }) {
const absVal = Math.abs(value); const reasons = recommendation?.reasons ?? [];
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";
return ( return (
<div className="bg-surface-1 rounded-xl px-3 py-2.5 border border-border-subtle"> <div className="glass-card-static p-5">
<div className="text-[10px] text-text-muted leading-tight">{label}</div> <SectionTitle title="推荐依据" />
<div className={`text-sm font-bold font-mono tabular-nums ${textColor}`}> <div className="space-y-2 mt-3">
{isPositive ? "+" : ""} {reasons.length ? reasons.map((reason, index) => (
{value.toFixed(1)}% <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>
<div className="h-1.5 rounded-full bg-surface-3 overflow-hidden mt-1.5"> {recommendation?.risk_note ? (
<div <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">
className={`h-full rounded-full transition-all duration-500 ${barColor}`} {recommendation.risk_note}
style={{ width: `${pct}%` }} </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> </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> </div>
); );
} }
function CapitalFlowBreakdown({ flow }: { flow: FlowRecord }) { function CapitalFlowBreakdown({ flow }: { flow: FlowRecord }) {
const maxDisplay = Math.max( const maxDisplay = Math.max(...[flow.elg_net, flow.lg_net, flow.md_net, flow.sm_net].map((value) => Math.abs(value)), 1);
...[flow.elg_net, flow.lg_net, flow.md_net, flow.sm_net].map(Math.abs),
1
);
const isMainInflow = flow.main_net_inflow > 0; const isMainInflow = flow.main_net_inflow > 0;
return ( return (
<div className="glass-card-static p-5"> <div className="glass-card-static p-5">
<div className="flex items-center gap-2 mb-4"> <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"}`} /> <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 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"}`}>
</h2> {isMainInflow ? "+" : ""}{formatFlowAmount(flow.main_net_inflow)}
<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>
</span> </span>
</div> </div>
<div className="space-y-2.5"> <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 }) { function FlowBar({ label, value, max }: { label: string; value: number; max: number }) {
const absVal = Math.abs(value); const absVal = Math.abs(value);
const pct = (absVal / max) * 100; const pct = (absVal / max) * 100;
@ -464,86 +548,52 @@ function FlowBar({ label, value, max }: { label: string; value: number; max: num
<div> <div>
<div className="flex items-center justify-between text-xs mb-1"> <div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-muted">{label}</span> <span className="text-text-muted">{label}</span>
<span <span className={`font-mono tabular-nums ${isInflow ? "text-red-400" : "text-emerald-400"}`}>
className={`font-mono tabular-nums ${isInflow ? "text-red-400" : "text-emerald-400"}`} {isInflow ? "+" : ""}{formatFlowAmount(value)}
>
{isInflow ? "+" : ""}
{formatFlowAmount(value)}
</span> </span>
</div> </div>
<div className="h-1.5 rounded-full bg-surface-2 overflow-hidden"> <div className="h-1.5 rounded-full bg-surface-2 overflow-hidden">
<div <div className={`h-full rounded-full ${isInflow ? "bg-red-400" : "bg-emerald-400"}`} style={{ width: `${pct}%` }} />
className={`h-full rounded-full transition-all duration-500 ${
isInflow ? "bg-red-400" : "bg-emerald-400"
}`}
style={{ width: `${pct}%` }}
/>
</div> </div>
</div> </div>
); );
} }
function SignalChip({ label, active, points }: { label: string; active: boolean; points: number }) { function SectionTitle({ title }: { title: string }) {
return ( return <div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">{title}</div>;
<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 DimensionScore({ label, sublabel, value, displayValue }: { label: string; sublabel: string; value: number; displayValue?: string }) { function getLifecycleLabel(status: string) {
const width = Math.min(value, 100); const labels: Record<string, string> = {
const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low"; candidate: "观察池",
const scoreColor = value >= 70 ? "text-amber-400" : value >= 50 ? "text-cyan-400" : "text-text-muted"; actionable: "可操作",
return ( tracking: "跟踪中",
<div> closed_win: "盈利结束",
<div className="flex items-baseline justify-between mb-1.5"> closed_loss: "亏损结束",
<div> expired: "到期复盘",
<span className={`text-xs font-semibold ${scoreColor}`}>{label}</span> invalidated: "已失效",
<span className="text-[10px] text-text-muted/40 ml-1">{sublabel}</span> };
</div> return labels[status] ?? status;
<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 PriceLevel({ label, value, color }: { label: string; value: number | null; color: string }) { function getActionPlanClass(actionPlan: string) {
return ( if (actionPlan === "可操作") return "border-red-500/20 bg-red-500/10 text-red-400";
<div className="bg-surface-1 rounded-xl px-3 py-2.5 border border-border-subtle"> if (actionPlan === "重点关注") return "border-amber-500/20 bg-amber-500/10 text-amber-400";
<div className="text-[10px] text-text-muted leading-tight">{label}</div> return "border-border-subtle bg-surface-1 text-text-muted";
<div className={`text-sm font-bold font-mono tabular-nums ${color}`}>
{value?.toFixed(2) ?? "—"}
</div>
</div>
);
} }
/* ── 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) { function formatDateTime(value?: string) {
if (score >= 70) return "#22c55e"; if (!value) return "暂无";
if (score >= 40) return "#eab308"; return value.replace("T", " ").slice(0, 16);
return "#ef4444";
} }
function formatBigNum(val: number): string { function formatBigNum(val: number): string {

View 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>
);
}

View 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;
}

View File

@ -1,344 +1,81 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useRef } from "react";
/* ── Animated data stream background ── */ const HIGHLIGHTS = [
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 分析", "风险提示", "入场信号"],
},
]; ];
export default function LandingPage() { export default function LandingPage() {
return ( return (
<div className="min-h-screen bg-bg-primary relative overflow-hidden"> <main className="min-h-screen bg-bg-primary text-text-primary relative overflow-hidden">
{/* Ambient glow */} <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%)]" />
<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" />
{/* ── Hero ── */} <div className="relative max-w-6xl mx-auto px-6 md:px-8 min-h-screen flex flex-col">
<section className="relative min-h-screen flex flex-col items-center justify-center px-6"> <header className="flex items-center justify-between py-6 animate-fade-in-up">
<DataStreamCanvas /> <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">
<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">
D D
</div> </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> </div>
<span className="text-xs text-text-muted/40">
&copy; {new Date().getFullYear()} Dragon AI Agent <Link
</span> href="/login"
</div> 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"
</footer> >
</div>
</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>
); );
} }

View File

@ -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"> <div className="glass-card-static p-4 sm:p-5 animate-fade-in-up">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-3 sm:mb-4"> <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> <span className="text-[10px] sm:text-xs text-text-muted font-mono tabular-nums">{data.trade_date}</span>
</div> </div>

View File

@ -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() { function DiagnoseIcon() {
return ( 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() { function UsersIcon() {
return ( return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> <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 ( return (
<nav className="flex-1 py-5 px-3 space-y-1"> <nav className="flex-1 py-5 px-3 space-y-1">
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="总览" /> <SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" />
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐列表" /> <SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" />
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块分析" /> <SideNavItem href="/strategy" icon={<StrategyIcon />} label="策略复盘" />
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="AI 诊断" /> <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" && ( {user?.role === "admin" && (
<SideNavItem href="/settings" icon={<SettingsIcon />} label="系统设置" /> <SideNavItem href="/settings" icon={<SettingsIcon />} label="系统设置" />
)} )}
@ -128,17 +149,17 @@ export function MobileBottomNav() {
return ( 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"> <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))]"> <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 /> <DashboardIcon />
</MobileNavItem> </MobileNavItem>
<MobileNavItem href="/recommendations" label="推荐"> <MobileNavItem href="/recommendations" label="推荐">
<TargetIcon /> <TargetIcon />
</MobileNavItem> </MobileNavItem>
<MobileNavItem href="/sectors" label="板块"> <MobileNavItem href="/strategy" label="策略">
<FireIcon /> <StrategyIcon />
</MobileNavItem> </MobileNavItem>
<MobileNavItem href="/diagnose" label="诊断"> <MobileNavItem href="/watchlists" label="自选">
<DiagnoseIcon /> <WatchlistIcon />
</MobileNavItem> </MobileNavItem>
</div> </div>
</nav> </nav>

View File

@ -7,7 +7,7 @@ export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) {
if (!sectors.length) { if (!sectors.length) {
return ( return (
<div className="glass-card-static p-5"> <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 className="text-sm text-text-muted text-center py-6"></div>
</div> </div>
); );
@ -19,7 +19,10 @@ export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) {
return ( return (
<div className="glass-card-static p-5"> <div className="glass-card-static p-5">
<div className="flex items-center gap-2 mb-4"> <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 && ( {hasRealtime && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400/80"> <span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400/80">

View File

@ -1,20 +1,10 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import { getLevelBadge } from "@/lib/utils";
import { getLevelBadge, getScoreColor } from "@/lib/utils";
import type { RecommendationData } from "@/lib/api"; 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 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 }> = { 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" } ? { label: "强中选强", style: "bg-amber-500/15 text-amber-400 border-amber-500/20" }
: null; : null;
const tag = signalInfo || legacyStrategy; const tag = signalInfo || legacyStrategy;
const actionPlanStyle: Record<string, string> = {
const hasLLM = rec.llm_analysis && rec.llm_analysis !== "AI 分析暂时不可用"; "可操作": "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 ( return (
<div className="glass-card p-4 group"> <div className="glass-card p-4 group">
{/* Clickable top section — navigates to stock detail */} {/* Clickable top section — navigates to stock detail */}
<a href={`/stock/${rec.ts_code}`} className="block"> <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="flex items-start justify-between mb-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap"> <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} {tag.label}
</span> </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>
<div className="text-[11px] text-text-muted mt-1 font-mono tabular-nums"> <div className="text-[11px] text-text-muted mt-1 font-mono tabular-nums">
{rec.ts_code} · {rec.sector} {rec.ts_code} · {rec.sector}
</div> </div>
</div> </div>
<div className="text-right shrink-0 ml-3"> <div className="text-right shrink-0 ml-3">
<div className={`text-xl font-bold font-mono tabular-nums tracking-tight ${getScoreColor(rec.score)}`}> <div className="text-[10px] text-text-muted uppercase tracking-wider">
{rec.score} AI
</div> </div>
<div className={`text-[10px] font-medium ${badge.text}`}> <div className="text-xs text-text-secondary mt-0.5">
{rec.level} {rec.action_plan ?? "观察"}
</div> </div>
</div> </div>
</div> </div>
{/* Score dimension bars */} {(rec.action_plan || rec.trigger_condition || rec.invalidation_condition || rec.suggested_position_pct) && (
<div className="grid grid-cols-4 gap-2 mb-3"> <div className="mb-3 rounded-xl bg-surface-1/70 border border-border-subtle p-3">
<ScoreBar label="供需" value={rec.supply_demand_score ?? 0} weight="50%" /> <div className="flex items-center justify-between gap-2 mb-2">
<ScoreBar label="形态" value={rec.price_action_score ?? 0} weight="40%" /> <div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">AI </div>
<ScoreBar label="趋势" value={rec.technical_score} weight="10%" /> <span className={`text-[10px] px-2 py-0.5 rounded-full border ${rec.action_plan ? actionPlanStyle[rec.action_plan] ?? actionPlanStyle["观察"] : actionPlanStyle["观察"]}`}>
<ScoreBar label="位置" value={rec.position_score ?? 50} weight="防追高" /> {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> </div>
{/* Price reference */} {/* Price reference */}
@ -93,6 +148,38 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
</div> </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 */} {/* Reasons */}
<div className="space-y-1.5"> <div className="space-y-1.5">
{rec.reasons.slice(0, 3).map((r, i) => ( {rec.reasons.slice(0, 3).map((r, i) => (
@ -104,46 +191,19 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
</div> </div>
</a> </a>
{/* ── AI Analysis — separate from the clickable link area ── */} <div className="mt-3 border-t border-border-subtle pt-2 flex items-center justify-between gap-3 text-[11px]">
{hasLLM ? ( <div className="text-text-muted">
<div className="mt-3 border-t border-border-subtle pt-3">
<button {rec.llm_score != null && (
onClick={() => setAiExpanded(!aiExpanded)} <span className="ml-2 font-mono tabular-nums text-cyan-400/80">
className="w-full flex items-center gap-2 text-xs text-cyan-400/80 font-medium hover:text-cyan-400 transition-colors" AI {rec.llm_score}/10
> </span>
<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> </div>
) : rec.llm_analysis === "AI 分析暂时不可用" ? ( <a href={`/stock/${rec.ts_code}`} className="shrink-0 text-cyan-400/80 hover:text-cyan-400 transition-colors">
<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" /> </a>
AI </div>
</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}
{/* Risk note */} {/* Risk note */}
{rec.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 }) { function ScoreBar({ label, value, weight }: { label: string; value: number; weight?: string }) {
const width = Math.min(value, 100); 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 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> </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>
);
}

View File

@ -70,6 +70,29 @@ export async function deleteAPI<T>(path: string): Promise<T> {
return res.json(); 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 { export interface MarketTemperatureData {
trade_date: string; trade_date: string;
temperature: number; temperature: number;
@ -117,6 +140,26 @@ export interface RecommendationData {
llm_score?: number | null; llm_score?: number | null;
scan_session: string; scan_session: string;
created_at: string | null; 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 { export interface LeadingStock {
@ -150,6 +193,14 @@ export interface SectorData {
export interface LatestResult { export interface LatestResult {
market_temperature: MarketTemperatureData | null; market_temperature: MarketTemperatureData | null;
recommendations: RecommendationData[]; recommendations: RecommendationData[];
strategy_profile?: {
strategy_id: string;
name: string;
description?: string;
buy_threshold?: number;
min_score?: number;
notes?: string[];
} | null;
} }
export interface DayGroup { export interface DayGroup {
@ -168,8 +219,11 @@ export interface PerformanceStats {
winning: number; winning: number;
win_rate: number; win_rate: number;
avg_return: number; avg_return: number;
avg_max_return: number;
avg_max_drawdown: number;
hit_target_count: number; hit_target_count: number;
hit_stop_count: number; hit_stop_count: number;
lifecycle_counts: Record<string, number>;
details: TrackedRecommendation[]; details: TrackedRecommendation[];
} }
@ -183,6 +237,13 @@ export interface TrackedRecommendation {
hit_target: boolean; hit_target: boolean;
hit_stop_loss: boolean; hit_stop_loss: boolean;
status: string; 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; track_date: string;
} }
@ -198,6 +259,178 @@ export interface DailyReviewResponse {
reviews: DailyReview[]; 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 ---------- // ---------- Sector Rotation ----------
export interface SectorRotationData { export interface SectorRotationData {