update
This commit is contained in:
parent
1e8c38dfc5
commit
212fda0bbf
Binary file not shown.
Binary file not shown.
@ -1,518 +0,0 @@
|
|||||||
"""Debug API — 系统日志与运行状态"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from app.core.deps import get_current_admin
|
|
||||||
from app.db.database import get_db
|
|
||||||
from app.config import settings, is_trading_hours
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/debug", tags=["debug"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/errors")
|
|
||||||
async def get_errors(
|
|
||||||
limit: int = 50,
|
|
||||||
source: str = None,
|
|
||||||
level: str = None,
|
|
||||||
q: str = None,
|
|
||||||
days: int = 7,
|
|
||||||
_admin: dict = Depends(get_current_admin),
|
|
||||||
):
|
|
||||||
"""获取错误日志(管理员)"""
|
|
||||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
async with get_db() as db:
|
|
||||||
conditions = ["created_at >= :start"]
|
|
||||||
params = {"start": start}
|
|
||||||
|
|
||||||
if source:
|
|
||||||
conditions.append("source = :source")
|
|
||||||
params["source"] = source
|
|
||||||
if level:
|
|
||||||
conditions.append("level = :level")
|
|
||||||
params["level"] = level
|
|
||||||
if q:
|
|
||||||
conditions.append("(message LIKE :q OR detail LIKE :q OR source LIKE :q)")
|
|
||||||
params["q"] = f"%{q.strip()}%"
|
|
||||||
|
|
||||||
where = " AND ".join(conditions)
|
|
||||||
|
|
||||||
# 总数
|
|
||||||
count_result = await db.execute(
|
|
||||||
text(f"SELECT COUNT(*) FROM error_logs WHERE {where}"), params
|
|
||||||
)
|
|
||||||
total = count_result.scalar() or 0
|
|
||||||
|
|
||||||
# 查询
|
|
||||||
params["limit"] = limit
|
|
||||||
result = await db.execute(
|
|
||||||
text(
|
|
||||||
f"SELECT id, source, level, message, detail, created_at "
|
|
||||||
f"FROM error_logs WHERE {where} "
|
|
||||||
f"ORDER BY created_at DESC LIMIT :limit"
|
|
||||||
),
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
rows = result.fetchall()
|
|
||||||
errors = []
|
|
||||||
for row in rows:
|
|
||||||
r = row._mapping
|
|
||||||
errors.append({
|
|
||||||
"id": r["id"],
|
|
||||||
"source": r["source"],
|
|
||||||
"level": r["level"],
|
|
||||||
"message": r["message"],
|
|
||||||
"detail": r["detail"] or "",
|
|
||||||
"created_at": str(r["created_at"]) if r["created_at"] else "",
|
|
||||||
})
|
|
||||||
|
|
||||||
# 可选的 source/level 列表(用于前端过滤)
|
|
||||||
sources_result = await db.execute(
|
|
||||||
text("SELECT DISTINCT source FROM error_logs ORDER BY source")
|
|
||||||
)
|
|
||||||
sources = [r[0] for r in sources_result.fetchall()]
|
|
||||||
|
|
||||||
levels_result = await db.execute(
|
|
||||||
text("SELECT DISTINCT level FROM error_logs ORDER BY level")
|
|
||||||
)
|
|
||||||
levels = [r[0] for r in levels_result.fetchall()]
|
|
||||||
|
|
||||||
source_counts_result = await db.execute(
|
|
||||||
text(f"SELECT source, COUNT(*) FROM error_logs WHERE {where} GROUP BY source ORDER BY COUNT(*) DESC"),
|
|
||||||
{key: value for key, value in params.items() if key != "limit"},
|
|
||||||
)
|
|
||||||
source_counts = {r[0]: r[1] for r in source_counts_result.fetchall()}
|
|
||||||
|
|
||||||
level_counts_result = await db.execute(
|
|
||||||
text(f"SELECT level, COUNT(*) FROM error_logs WHERE {where} GROUP BY level ORDER BY COUNT(*) DESC"),
|
|
||||||
{key: value for key, value in params.items() if key != "limit"},
|
|
||||||
)
|
|
||||||
level_counts = {r[0]: r[1] for r in level_counts_result.fetchall()}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total": total,
|
|
||||||
"errors": errors,
|
|
||||||
"sources": sources,
|
|
||||||
"levels": levels,
|
|
||||||
"source_counts": source_counts,
|
|
||||||
"level_counts": level_counts,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/errors")
|
|
||||||
async def clear_errors(
|
|
||||||
days: int = 30,
|
|
||||||
_admin: dict = Depends(get_current_admin),
|
|
||||||
):
|
|
||||||
"""清除旧错误日志(管理员)"""
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
async with get_db() as db:
|
|
||||||
result = await db.execute(
|
|
||||||
text("DELETE FROM error_logs WHERE created_at < :cutoff"),
|
|
||||||
{"cutoff": cutoff},
|
|
||||||
)
|
|
||||||
deleted = result.rowcount
|
|
||||||
await db.commit()
|
|
||||||
return {"status": "ok", "deleted": deleted}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/system")
|
|
||||||
async def system_status(_admin: dict = Depends(get_current_admin)):
|
|
||||||
"""系统运行状态摘要(管理员)"""
|
|
||||||
from app.engine.recommender import _scan_running, _scan_lock
|
|
||||||
|
|
||||||
async with get_db() as db:
|
|
||||||
# 各表数据量
|
|
||||||
tables_counts = {}
|
|
||||||
for t in ["recommendations", "sector_heat", "market_temperature",
|
|
||||||
"recommendation_tracking", "stock_diagnoses",
|
|
||||||
"error_logs", "scan_process_logs", "users"]:
|
|
||||||
result = await db.execute(text(f"SELECT COUNT(*) FROM {t}"))
|
|
||||||
tables_counts[t] = result.scalar() or 0
|
|
||||||
|
|
||||||
# 最近 24h 错误数
|
|
||||||
since = (datetime.now() - timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
result = await db.execute(
|
|
||||||
text("SELECT COUNT(*) FROM error_logs WHERE created_at >= :since"),
|
|
||||||
{"since": since},
|
|
||||||
)
|
|
||||||
recent_errors = result.scalar() or 0
|
|
||||||
|
|
||||||
# 最近错误
|
|
||||||
result = await db.execute(
|
|
||||||
text("SELECT source, message, created_at FROM error_logs ORDER BY created_at DESC LIMIT 5")
|
|
||||||
)
|
|
||||||
last_errors = [
|
|
||||||
{"source": r[0], "message": r[1], "created_at": str(r[2])}
|
|
||||||
for r in result.fetchall()
|
|
||||||
]
|
|
||||||
|
|
||||||
# 数据库文件大小
|
|
||||||
db_path = settings.database_url.replace("sqlite:///", "")
|
|
||||||
db_size_mb = 0
|
|
||||||
if os.path.exists(db_path):
|
|
||||||
db_size_mb = round(os.path.getsize(db_path) / 1024 / 1024, 2)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"is_trading": is_trading_hours(),
|
|
||||||
"scan_running": _scan_running,
|
|
||||||
"scan_locked": _scan_lock.locked(),
|
|
||||||
"recent_errors": recent_errors,
|
|
||||||
"last_errors": last_errors,
|
|
||||||
"tables_counts": tables_counts,
|
|
||||||
"db_size_mb": db_size_mb,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_detail(raw: str | None) -> dict:
|
|
||||||
if not raw:
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw)
|
|
||||||
return parsed if isinstance(parsed, dict) else {"value": parsed}
|
|
||||||
except Exception:
|
|
||||||
return {"raw": raw}
|
|
||||||
|
|
||||||
|
|
||||||
def _row_observation(row) -> dict:
|
|
||||||
r = row._mapping
|
|
||||||
return {
|
|
||||||
"id": r["id"],
|
|
||||||
"scan_session": r["scan_session"],
|
|
||||||
"scan_mode": r["scan_mode"] or "",
|
|
||||||
"ts_code": r["ts_code"],
|
|
||||||
"name": r["name"],
|
|
||||||
"theme_name": r["theme_name"] or "",
|
|
||||||
"stock_role": r["stock_role"] or "",
|
|
||||||
"action_plan": r["action_plan"] or "观察",
|
|
||||||
"final_score": r["final_score"] or 0,
|
|
||||||
"catalyst_score": r["catalyst_score"] or 0,
|
|
||||||
"theme_money_score": r["theme_money_score"] or 0,
|
|
||||||
"stock_money_score": r["stock_money_score"] or 0,
|
|
||||||
"emotion_role_score": r["emotion_role_score"] or 0,
|
|
||||||
"timing_score": r["timing_score"] or 0,
|
|
||||||
"entry_signal_type": r["entry_signal_type"] or "none",
|
|
||||||
"elimination_reason": r["elimination_reason"] or "",
|
|
||||||
"detail": _decode_detail(r["detail_json"]),
|
|
||||||
"created_at": str(r["created_at"]) if r["created_at"] else "",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/scan-logs")
|
|
||||||
async def get_scan_logs(
|
|
||||||
limit: int = 100,
|
|
||||||
scan_session: str = None,
|
|
||||||
days: int = 7,
|
|
||||||
_admin: dict = Depends(get_current_admin),
|
|
||||||
):
|
|
||||||
"""获取筛选过程日志(管理员)"""
|
|
||||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
limit = max(1, min(limit, 300))
|
|
||||||
|
|
||||||
async with get_db() as db:
|
|
||||||
selected_session = scan_session
|
|
||||||
if not selected_session:
|
|
||||||
latest = await db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT scan_session FROM scan_process_logs "
|
|
||||||
"WHERE created_at >= :start "
|
|
||||||
"ORDER BY created_at DESC LIMIT 1"
|
|
||||||
),
|
|
||||||
{"start": start},
|
|
||||||
)
|
|
||||||
selected_session = latest.scalar()
|
|
||||||
|
|
||||||
if not selected_session:
|
|
||||||
return {"scan_session": None, "logs": []}
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT id, scan_session, scan_mode, stage, stage_label, status, "
|
|
||||||
"input_count, output_count, filtered_count, summary, detail_json, created_at "
|
|
||||||
"FROM scan_process_logs "
|
|
||||||
"WHERE created_at >= :start AND scan_session = :scan_session "
|
|
||||||
"ORDER BY created_at ASC LIMIT :limit"
|
|
||||||
),
|
|
||||||
{"start": start, "scan_session": selected_session, "limit": limit},
|
|
||||||
)
|
|
||||||
rows = result.fetchall()
|
|
||||||
|
|
||||||
logs = []
|
|
||||||
for row in rows:
|
|
||||||
r = row._mapping
|
|
||||||
logs.append({
|
|
||||||
"id": r["id"],
|
|
||||||
"scan_session": r["scan_session"],
|
|
||||||
"scan_mode": r["scan_mode"] or "",
|
|
||||||
"stage": r["stage"],
|
|
||||||
"stage_label": r["stage_label"],
|
|
||||||
"status": r["status"] or "ok",
|
|
||||||
"input_count": r["input_count"] or 0,
|
|
||||||
"output_count": r["output_count"] or 0,
|
|
||||||
"filtered_count": r["filtered_count"] or 0,
|
|
||||||
"summary": r["summary"] or "",
|
|
||||||
"detail": _decode_detail(r["detail_json"]),
|
|
||||||
"created_at": str(r["created_at"]) if r["created_at"] else "",
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"scan_session": selected_session,
|
|
||||||
"logs": logs,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/research-observations")
|
|
||||||
async def get_research_observations(
|
|
||||||
scan_session: str = None,
|
|
||||||
limit: int = 80,
|
|
||||||
days: int = 7,
|
|
||||||
_admin: dict = Depends(get_current_admin),
|
|
||||||
):
|
|
||||||
"""获取候选股投研观察记录(管理员)"""
|
|
||||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
limit = max(1, min(limit, 200))
|
|
||||||
|
|
||||||
async with get_db() as db:
|
|
||||||
selected_session = scan_session
|
|
||||||
if not selected_session:
|
|
||||||
latest = await db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT scan_session FROM research_observations "
|
|
||||||
"WHERE created_at >= :start ORDER BY created_at DESC LIMIT 1"
|
|
||||||
),
|
|
||||||
{"start": start},
|
|
||||||
)
|
|
||||||
selected_session = latest.scalar()
|
|
||||||
|
|
||||||
if not selected_session:
|
|
||||||
return {"scan_session": None, "observations": [], "reason_counts": {}}
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT id, scan_session, scan_mode, ts_code, name, theme_name, stock_role, "
|
|
||||||
"action_plan, final_score, catalyst_score, theme_money_score, stock_money_score, "
|
|
||||||
"emotion_role_score, timing_score, entry_signal_type, elimination_reason, detail_json, created_at "
|
|
||||||
"FROM research_observations "
|
|
||||||
"WHERE created_at >= :start AND scan_session = :scan_session "
|
|
||||||
"ORDER BY final_score DESC LIMIT :limit"
|
|
||||||
),
|
|
||||||
{"start": start, "scan_session": selected_session, "limit": limit},
|
|
||||||
)
|
|
||||||
rows = result.fetchall()
|
|
||||||
|
|
||||||
observations = [_row_observation(row) for row in rows]
|
|
||||||
reason_counts = {}
|
|
||||||
for item in observations:
|
|
||||||
for part in (item["elimination_reason"] or "未知").split(";"):
|
|
||||||
if not part:
|
|
||||||
continue
|
|
||||||
reason_counts[part] = reason_counts.get(part, 0) + 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
"scan_session": selected_session,
|
|
||||||
"observations": observations,
|
|
||||||
"reason_counts": reason_counts,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/scan-sessions")
|
|
||||||
async def get_scan_sessions(
|
|
||||||
days: int = 7,
|
|
||||||
limit: int = 30,
|
|
||||||
_admin: dict = Depends(get_current_admin),
|
|
||||||
):
|
|
||||||
"""获取筛选会话摘要(管理员)"""
|
|
||||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
limit = max(1, min(limit, 100))
|
|
||||||
|
|
||||||
async with get_db() as db:
|
|
||||||
result = await db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT scan_session, scan_mode, stage, status, input_count, "
|
|
||||||
"output_count, filtered_count, summary, created_at "
|
|
||||||
"FROM scan_process_logs "
|
|
||||||
"WHERE created_at >= :start "
|
|
||||||
"ORDER BY created_at DESC LIMIT 1000"
|
|
||||||
),
|
|
||||||
{"start": start},
|
|
||||||
)
|
|
||||||
rows = result.fetchall()
|
|
||||||
|
|
||||||
sessions = {}
|
|
||||||
order = []
|
|
||||||
for row in rows:
|
|
||||||
r = row._mapping
|
|
||||||
session_id = r["scan_session"]
|
|
||||||
if session_id not in sessions:
|
|
||||||
sessions[session_id] = {
|
|
||||||
"scan_session": session_id,
|
|
||||||
"scan_mode": r["scan_mode"] or "",
|
|
||||||
"created_at": str(r["created_at"]) if r["created_at"] else "",
|
|
||||||
"stage_count": 0,
|
|
||||||
"status": "ok",
|
|
||||||
"input_count": 0,
|
|
||||||
"final_count": 0,
|
|
||||||
"drop_count": 0,
|
|
||||||
"last_summary": r["summary"] or "",
|
|
||||||
}
|
|
||||||
order.append(session_id)
|
|
||||||
|
|
||||||
item = sessions[session_id]
|
|
||||||
item["stage_count"] += 1
|
|
||||||
item["input_count"] = max(item["input_count"], int(r["input_count"] or 0))
|
|
||||||
item["drop_count"] += int(r["filtered_count"] or 0)
|
|
||||||
if r["stage"] == "final_filter" and item["final_count"] == 0:
|
|
||||||
item["final_count"] = int(r["output_count"] or 0)
|
|
||||||
item["last_summary"] = r["summary"] or item["last_summary"]
|
|
||||||
|
|
||||||
status = (r["status"] or "ok").lower()
|
|
||||||
if status in {"failed", "error", "critical"}:
|
|
||||||
item["status"] = "failed"
|
|
||||||
elif status in {"warning", "empty"} and item["status"] == "ok":
|
|
||||||
item["status"] = status
|
|
||||||
|
|
||||||
return {
|
|
||||||
"sessions": [sessions[sid] for sid in order[:limit]],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/data-source-health")
|
|
||||||
async def get_data_source_health(
|
|
||||||
days: int = 7,
|
|
||||||
_admin: dict = Depends(get_current_admin),
|
|
||||||
):
|
|
||||||
"""数据源健康摘要(管理员,只读)。"""
|
|
||||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
known_sources = ["eastmoney", "tencent", "tushare", "akshare", "sina", "news", "tushare_news"]
|
|
||||||
health = {
|
|
||||||
source: {
|
|
||||||
"source": source,
|
|
||||||
"status": "ok",
|
|
||||||
"error_count": 0,
|
|
||||||
"warning_count": 0,
|
|
||||||
"last_error": "",
|
|
||||||
"last_seen_at": "",
|
|
||||||
}
|
|
||||||
for source in known_sources
|
|
||||||
}
|
|
||||||
|
|
||||||
async with get_db() as db:
|
|
||||||
result = await db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT source, level, message, created_at FROM error_logs "
|
|
||||||
"WHERE created_at >= :start "
|
|
||||||
"ORDER BY created_at DESC LIMIT 500"
|
|
||||||
),
|
|
||||||
{"start": start},
|
|
||||||
)
|
|
||||||
rows = result.fetchall()
|
|
||||||
|
|
||||||
table_rows = {}
|
|
||||||
for table_name, sql in {
|
|
||||||
"market_temperature": "SELECT trade_date, created_at FROM market_temperature ORDER BY id DESC LIMIT 1",
|
|
||||||
"sector_heat": "SELECT trade_date, created_at FROM sector_heat ORDER BY id DESC LIMIT 1",
|
|
||||||
"recommendations": "SELECT created_at FROM recommendations ORDER BY id DESC LIMIT 1",
|
|
||||||
"news_items": "SELECT created_at FROM news_items ORDER BY id DESC LIMIT 1",
|
|
||||||
"catalysts": "SELECT created_at FROM catalysts ORDER BY id DESC LIMIT 1",
|
|
||||||
}.items():
|
|
||||||
row = (await db.execute(text(sql))).fetchone()
|
|
||||||
table_rows[table_name] = dict(row._mapping) if row else {}
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
r = row._mapping
|
|
||||||
source_text = str(r["source"] or "").lower()
|
|
||||||
matched = next((source for source in known_sources if source in source_text), source_text or "unknown")
|
|
||||||
if matched not in health:
|
|
||||||
health[matched] = {
|
|
||||||
"source": matched,
|
|
||||||
"status": "ok",
|
|
||||||
"error_count": 0,
|
|
||||||
"warning_count": 0,
|
|
||||||
"last_error": "",
|
|
||||||
"last_seen_at": "",
|
|
||||||
}
|
|
||||||
item = health[matched]
|
|
||||||
level_text = str(r["level"] or "error").lower()
|
|
||||||
if level_text in {"warning", "warn"}:
|
|
||||||
item["warning_count"] += 1
|
|
||||||
if item["status"] == "ok":
|
|
||||||
item["status"] = "warning"
|
|
||||||
else:
|
|
||||||
item["error_count"] += 1
|
|
||||||
item["status"] = "error"
|
|
||||||
if not item["last_error"]:
|
|
||||||
item["last_error"] = r["message"] or ""
|
|
||||||
item["last_seen_at"] = str(r["created_at"] or "")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"days": days,
|
|
||||||
"sources": sorted(health.values(), key=lambda item: (item["status"] != "error", item["status"] != "warning", item["source"])),
|
|
||||||
"freshness": {
|
|
||||||
key: {k: str(v or "") for k, v in value.items()}
|
|
||||||
for key, value in table_rows.items()
|
|
||||||
},
|
|
||||||
"generated_at": datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks")
|
|
||||||
async def get_tasks(_admin: dict = Depends(get_current_admin)):
|
|
||||||
"""后台任务中心摘要(管理员,只读)。"""
|
|
||||||
from app.engine.scheduler import scheduler
|
|
||||||
from app.engine.recommender import _scan_running, _scan_lock
|
|
||||||
|
|
||||||
jobs = []
|
|
||||||
for job in scheduler.get_jobs():
|
|
||||||
jobs.append({
|
|
||||||
"id": job.id,
|
|
||||||
"name": job.name,
|
|
||||||
"next_run_time": str(job.next_run_time) if job.next_run_time else "",
|
|
||||||
"trigger": str(job.trigger),
|
|
||||||
})
|
|
||||||
|
|
||||||
async with get_db() as db:
|
|
||||||
recent_scan = await db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT scan_session, scan_mode, stage, status, output_count, summary, created_at "
|
|
||||||
"FROM scan_process_logs ORDER BY created_at DESC LIMIT 12"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
recent_errors = await db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT source, level, message, created_at FROM error_logs "
|
|
||||||
"ORDER BY created_at DESC LIMIT 8"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"scheduler_running": scheduler.running,
|
|
||||||
"scan_running": _scan_running,
|
|
||||||
"scan_locked": _scan_lock.locked(),
|
|
||||||
"job_count": len(jobs),
|
|
||||||
"jobs": sorted(jobs, key=lambda item: item["next_run_time"] or "9999"),
|
|
||||||
"recent_scan_logs": [
|
|
||||||
{
|
|
||||||
"scan_session": r._mapping["scan_session"],
|
|
||||||
"scan_mode": r._mapping["scan_mode"] or "",
|
|
||||||
"stage": r._mapping["stage"],
|
|
||||||
"status": r._mapping["status"] or "ok",
|
|
||||||
"output_count": r._mapping["output_count"] or 0,
|
|
||||||
"summary": r._mapping["summary"] or "",
|
|
||||||
"created_at": str(r._mapping["created_at"] or ""),
|
|
||||||
}
|
|
||||||
for r in recent_scan.fetchall()
|
|
||||||
],
|
|
||||||
"recent_errors": [
|
|
||||||
{
|
|
||||||
"source": r._mapping["source"],
|
|
||||||
"level": r._mapping["level"],
|
|
||||||
"message": r._mapping["message"],
|
|
||||||
"created_at": str(r._mapping["created_at"] or ""),
|
|
||||||
}
|
|
||||||
for r in recent_errors.fetchall()
|
|
||||||
],
|
|
||||||
"generated_at": datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.data.cache import cache
|
|
||||||
from app.engine.recommender import get_latest_recommendations
|
from app.engine.recommender import get_latest_recommendations
|
||||||
from app.config import settings, is_trading_hours, should_prefer_realtime_today, today_trade_date
|
from app.config import is_trading_hours, should_prefer_realtime_today, today_trade_date
|
||||||
from app.core.deps import get_current_admin
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/market", tags=["market"])
|
router = APIRouter(prefix="/api/market", tags=["market"])
|
||||||
|
|
||||||
@ -48,158 +46,5 @@ async def get_temperature():
|
|||||||
|
|
||||||
@router.get("/overview")
|
@router.get("/overview")
|
||||||
async def get_overview():
|
async def get_overview():
|
||||||
"""市场概况快照。
|
"""市场概况快照。"""
|
||||||
|
|
||||||
页面访问不拉腾讯/Tushare。当前库里还没有指数快照表,先返回空数组。
|
|
||||||
后续应由扫描任务把指数概览写入本地表后再展示。
|
|
||||||
"""
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/strategy-board")
|
|
||||||
async def get_strategy_board():
|
|
||||||
"""获取今日市场作战面板(只读,不触发 LLM)"""
|
|
||||||
cache_key = "market:strategy_board:rules"
|
|
||||||
cached = cache.get(cache_key)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
from app.llm.strategy_board import build_strategy_board
|
|
||||||
result = await build_strategy_board(include_llm=False)
|
|
||||||
cache.set(cache_key, result, settings.cache_ttl_realtime)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/strategy-iteration")
|
|
||||||
async def get_strategy_iteration(limit: int = 50):
|
|
||||||
"""获取策略复盘迭代建议(只读,不触发 LLM)"""
|
|
||||||
cache_key = f"market:strategy_iteration:{limit}:rules"
|
|
||||||
cached = cache.get(cache_key)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
from app.llm.strategy_iteration import build_strategy_iteration_report
|
|
||||||
result = await build_strategy_iteration_report(limit=limit, include_llm=False)
|
|
||||||
cache.set(cache_key, result, settings.cache_ttl_realtime)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/strategy-configs")
|
|
||||||
async def get_strategy_configs(_admin: dict = Depends(get_current_admin)):
|
|
||||||
"""获取当前策略配置中心状态。"""
|
|
||||||
from app.llm.strategy_config import (
|
|
||||||
get_active_prompt_configs,
|
|
||||||
get_active_strategy_configs,
|
|
||||||
get_recent_config_changes,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"strategies": await get_active_strategy_configs(),
|
|
||||||
"prompts": await get_active_prompt_configs(),
|
|
||||||
"changes": await get_recent_config_changes(limit=30),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/strategy-configs/{strategy_id}/rollback")
|
|
||||||
async def rollback_strategy_config(strategy_id: str, _admin: dict = Depends(get_current_admin)):
|
|
||||||
"""回滚某个策略到上一配置版本。"""
|
|
||||||
from app.llm.strategy_config import rollback_strategy_config as rollback
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await rollback(strategy_id)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
cache.delete("market:strategy_board:rules")
|
|
||||||
cache.delete("market:strategy_iteration:80:rules")
|
|
||||||
cache.delete("market:strategy_iteration:50:rules")
|
|
||||||
return {"status": "ok", "strategy": result}
|
|
||||||
|
|
||||||
|
|
||||||
@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()
|
|
||||||
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 ""
|
|
||||||
today = today_trade_date()
|
|
||||||
sector_lagging = bool(latest_sector_date and latest_sector_date.replace("-", "") < today)
|
|
||||||
market_lagging = bool(latest_market_date and latest_market_date.replace("-", "") < today)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"scan_running": _scan_running,
|
|
||||||
"scan_mode": "realtime_today" if should_prefer_realtime_today(latest_market_date) 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 "",
|
|
||||||
"status": "stale" if sector_lagging or market_lagging else "fresh" if latest_market_date else "empty",
|
|
||||||
"message": (
|
|
||||||
f"板块快照仍停留在 {latest_sector_date},展示层将优先使用今日实时板块榜。"
|
|
||||||
if sector_lagging else
|
|
||||||
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
|
|
||||||
result = await build_strategy_board(include_llm=True)
|
|
||||||
cache.delete("market:strategy_board:rules")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
result = await build_strategy_iteration_report(limit=limit, include_llm=True, apply_auto_config=True)
|
|
||||||
cache.delete(f"market:strategy_iteration:{limit}:rules")
|
|
||||||
cache.delete("market:strategy_board:rules")
|
|
||||||
return result
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -8,6 +8,7 @@ import traceback
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
|
||||||
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.engine.watchlist import analyze_watchlist_for_all_users
|
||||||
@ -26,7 +27,6 @@ async def _run_scan(session_name: str):
|
|||||||
rec_count = len(result.get("recommendations", []))
|
rec_count = len(result.get("recommendations", []))
|
||||||
logger.info(f"扫描完成: {rec_count} 只推荐股票")
|
logger.info(f"扫描完成: {rec_count} 只推荐股票")
|
||||||
|
|
||||||
# 通过 WebSocket 推送更新
|
|
||||||
await broadcast_update({
|
await broadcast_update({
|
||||||
"type": "scan_update",
|
"type": "scan_update",
|
||||||
"session": session_name,
|
"session": session_name,
|
||||||
@ -71,27 +71,13 @@ async def _run_watchlist_analysis():
|
|||||||
await log_error("scheduler", f"自选股定时分析失败: {e}", detail=traceback.format_exc())
|
await log_error("scheduler", f"自选股定时分析失败: {e}", detail=traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
async def _run_strategy_iteration():
|
async def _run_trigger_monitor():
|
||||||
"""收盘后生成策略复盘,并允许小幅自动配置调整。"""
|
"""盘中买点触发监控 — 每分钟检查埋伏池是否命中条件。"""
|
||||||
logger.info("=== 开始策略复盘与配置校准 ===")
|
|
||||||
try:
|
try:
|
||||||
from app.llm.strategy_iteration import build_strategy_iteration_report
|
from app.engine.trigger_monitor import check_triggers
|
||||||
report = await build_strategy_iteration_report(limit=80, include_llm=False, apply_auto_config=True)
|
await check_triggers()
|
||||||
logger.info(
|
|
||||||
"策略复盘完成: sample=%s auto_change=%s",
|
|
||||||
report.get("sample_size", 0),
|
|
||||||
bool(report.get("auto_config_change")),
|
|
||||||
)
|
|
||||||
await broadcast_update({
|
|
||||||
"type": "strategy_iteration_ready",
|
|
||||||
"sample_size": report.get("sample_size", 0),
|
|
||||||
"auto_config_changed": bool(report.get("auto_config_change")),
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"策略复盘自动校准失败: {e}")
|
logger.debug(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():
|
||||||
@ -113,13 +99,13 @@ def setup_scheduler():
|
|||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 盘前准备 09:00 - 计算前一日市场温度和板块数据
|
# 盘前埋伏扫描 09:00
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_run_scan, CronTrigger(hour=9, minute=0, day_of_week="mon-fri"),
|
_run_scan, CronTrigger(hour=9, minute=0, day_of_week="mon-fri"),
|
||||||
args=["pre_market"], id="pre_market", replace_existing=True
|
args=["pre_market_ambush"], id="pre_market_ambush", replace_existing=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# 盘中扫描:按交易节奏执行,避免高频重复计算
|
# 盘中扫描
|
||||||
scan_schedule = [
|
scan_schedule = [
|
||||||
("morning_open_0935", 9, 35, "morning_open"),
|
("morning_open_0935", 9, 35, "morning_open"),
|
||||||
("morning_open_0950", 9, 50, "morning_open"),
|
("morning_open_0950", 9, 50, "morning_open"),
|
||||||
@ -141,7 +127,7 @@ def setup_scheduler():
|
|||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 收盘总结 16:00(Tushare 日线数据通常在 15:30 后更新完成)
|
# 收盘总结 16:00
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_run_scan, CronTrigger(hour=16, minute=0, day_of_week="mon-fri"),
|
_run_scan, CronTrigger(hour=16, minute=0, day_of_week="mon-fri"),
|
||||||
args=["post_market"], id="post_market", replace_existing=True
|
args=["post_market"], id="post_market", replace_existing=True
|
||||||
@ -152,9 +138,12 @@ def setup_scheduler():
|
|||||||
id="watchlist_analysis", replace_existing=True
|
id="watchlist_analysis", replace_existing=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 盘中买点触发监控:9:35-15:00 每分钟执行
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_run_strategy_iteration, CronTrigger(hour=16, minute=35, day_of_week="mon-fri"),
|
_run_trigger_monitor,
|
||||||
id="strategy_iteration", replace_existing=True
|
IntervalTrigger(minutes=1, start_date="2020-01-01 09:35:00", end_date="2020-01-01 15:00:00"),
|
||||||
|
id="trigger_monitor",
|
||||||
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("盘中调度器已配置完成")
|
logger.info("盘中调度器已配置完成")
|
||||||
|
|||||||
@ -32,7 +32,6 @@ from app.analysis.signals import generate_signals
|
|||||||
from app.analysis.intraday import (
|
from app.analysis.intraday import (
|
||||||
intraday_active_market_recall,
|
intraday_active_market_recall,
|
||||||
intraday_market_temperature,
|
intraday_market_temperature,
|
||||||
intraday_filter_stocks,
|
|
||||||
intraday_sector_scan,
|
intraday_sector_scan,
|
||||||
)
|
)
|
||||||
from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation
|
from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation
|
||||||
@ -156,7 +155,7 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
|
|||||||
merge_sectors_to_themes(await intraday_sector_scan(hot_sectors), limit=settings.top_sector_count)
|
merge_sectors_to_themes(await intraday_sector_scan(hot_sectors), limit=settings.top_sector_count)
|
||||||
)
|
)
|
||||||
|
|
||||||
strategy_profile = await select_strategy_profile(market_temp, hot_sectors, intraday)
|
strategy_profile = await select_strategy_profile(market_temp, hot_sectors, intraday, scan_session=scan_session)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"=== 今日策略: {strategy_profile.name} ({strategy_profile.strategy_id}) "
|
f"=== 今日策略: {strategy_profile.name} ({strategy_profile.strategy_id}) "
|
||||||
f"threshold={strategy_profile.buy_threshold} min_score={strategy_profile.min_score} ==="
|
f"threshold={strategy_profile.buy_threshold} min_score={strategy_profile.min_score} ==="
|
||||||
@ -244,6 +243,27 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
|
|||||||
detail={"requested": quote_requested, "updated": quote_updated, "error": quote_error},
|
detail={"requested": quote_requested, "updated": quote_updated, "error": quote_error},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── 盘中注入实时资金流(东方财富 f62 主力净流入) ──
|
||||||
|
if intraday and candidates:
|
||||||
|
flow_updated = 0
|
||||||
|
try:
|
||||||
|
from app.data.eastmoney_client import get_a_share_realtime_ranking
|
||||||
|
all_quotes = await get_a_share_realtime_ranking(sort_by="f62", descending=True, page_size=3000)
|
||||||
|
if all_quotes:
|
||||||
|
flow_map = {
|
||||||
|
item.get("ts_code", ""): float(item.get("main_net_inflow", 0) or 0) / 10000
|
||||||
|
for item in all_quotes if item.get("ts_code")
|
||||||
|
}
|
||||||
|
for c in candidates:
|
||||||
|
ts_code = c.get("ts_code", "")
|
||||||
|
if ts_code in flow_map:
|
||||||
|
c["main_net_inflow"] = flow_map[ts_code]
|
||||||
|
c["inflow_ratio"] = 0 # 实时无法算占比,置 0
|
||||||
|
flow_updated += 1
|
||||||
|
logger.info(f"盘中实时资金流注入: {flow_updated}/{len(candidates)} 只")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"盘中实时资金流注入失败,使用原始数据: {e}")
|
||||||
|
|
||||||
# ── Step 3: 规则评分与交易计划 ──
|
# ── Step 3: 规则评分与交易计划 ──
|
||||||
logger.info("=== Step 3: 规则评分与交易计划 ===")
|
logger.info("=== Step 3: 规则评分与交易计划 ===")
|
||||||
scoring_metrics: dict = {}
|
scoring_metrics: dict = {}
|
||||||
@ -670,21 +690,13 @@ async def _build_candidate_pool(
|
|||||||
|
|
||||||
if intraday:
|
if intraday:
|
||||||
try:
|
try:
|
||||||
intraday_candidates = await intraday_filter_stocks(hot_sectors)
|
intraday_candidates = await intraday_active_market_recall(limit=settings.candidate_pool_limit)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"盘中异动召回失败: {e}")
|
logger.warning(f"盘中活跃股召回失败: {e}")
|
||||||
intraday_candidates = []
|
intraday_candidates = []
|
||||||
_merge_candidate_batch(merged, intraday_candidates, route="intraday_active")
|
_merge_candidate_batch(merged, intraday_candidates, route="intraday_active")
|
||||||
|
|
||||||
try:
|
|
||||||
realtime_candidates = await intraday_active_market_recall(limit=settings.candidate_pool_limit)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"实时全市场召回失败: {e}")
|
|
||||||
realtime_candidates = []
|
|
||||||
_merge_candidate_batch(merged, realtime_candidates, route="realtime_market")
|
|
||||||
else:
|
else:
|
||||||
intraday_candidates = []
|
intraday_candidates = []
|
||||||
realtime_candidates = []
|
|
||||||
|
|
||||||
candidates = list(merged.values())
|
candidates = list(merged.values())
|
||||||
candidates.sort(key=lambda item: (
|
candidates.sort(key=lambda item: (
|
||||||
@ -698,7 +710,7 @@ async def _build_candidate_pool(
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Step 2 多路召回完成: sector={len(sector_candidates)} "
|
f"Step 2 多路召回完成: sector={len(sector_candidates)} "
|
||||||
f"trend={len(trend_candidates)} "
|
f"trend={len(trend_candidates)} "
|
||||||
f"{'intraday=' + str(len(intraday_candidates)) + ' realtime=' + str(len(realtime_candidates)) if intraday else ''} "
|
f"{'intraday=' + str(len(intraday_candidates)) if intraday else ''} "
|
||||||
f"→ merged={len(top)}"
|
f"→ merged={len(top)}"
|
||||||
)
|
)
|
||||||
if metrics is not None:
|
if metrics is not None:
|
||||||
@ -706,7 +718,6 @@ async def _build_candidate_pool(
|
|||||||
"sector_recall": len(sector_candidates),
|
"sector_recall": len(sector_candidates),
|
||||||
"trend_scan": len(trend_candidates),
|
"trend_scan": len(trend_candidates),
|
||||||
"intraday_active": len(intraday_candidates),
|
"intraday_active": len(intraday_candidates),
|
||||||
"realtime_market": len(realtime_candidates),
|
|
||||||
}
|
}
|
||||||
metrics.update({
|
metrics.update({
|
||||||
"route_counts": route_counts,
|
"route_counts": route_counts,
|
||||||
@ -949,7 +960,6 @@ async def _build_recommendations(
|
|||||||
EntrySignal,
|
EntrySignal,
|
||||||
)
|
)
|
||||||
from app.analysis.signals import generate_signals
|
from app.analysis.signals import generate_signals
|
||||||
from app.analysis.capital_flow import _score_valuation
|
|
||||||
|
|
||||||
# 名称和行业映射
|
# 名称和行业映射
|
||||||
stock_basic = tushare_client.get_stock_basic()
|
stock_basic = tushare_client.get_stock_basic()
|
||||||
@ -1017,7 +1027,7 @@ async def _build_recommendations(
|
|||||||
supply_demand_score = score_supply_demand(df)
|
supply_demand_score = score_supply_demand(df)
|
||||||
price_action_score = _score_price_action(df, entry_signal)
|
price_action_score = _score_price_action(df, entry_signal)
|
||||||
trend_score = _score_trend(df)
|
trend_score = _score_trend(df)
|
||||||
capital_score = _score_capital_simple(stock)
|
capital_score = 0 # 不再单独算 capital_simple,统一走 stock_money
|
||||||
flow_momentum_score = _score_flow_momentum(stock, sector, hot_sectors)
|
flow_momentum_score = _score_flow_momentum(stock, sector, hot_sectors)
|
||||||
sector_stage = _get_sector_stage(sector, hot_sectors)
|
sector_stage = _get_sector_stage(sector, hot_sectors)
|
||||||
hot_theme_match = find_hot_theme_match(sector, hot_sectors)
|
hot_theme_match = find_hot_theme_match(sector, hot_sectors)
|
||||||
@ -1116,12 +1126,7 @@ async def _build_recommendations(
|
|||||||
final_score *= 1.03
|
final_score *= 1.03
|
||||||
boosts.append({"label": "新闻催化加权", "value": "+3%", "reason": catalyst_reasons[0] if catalyst_reasons else f"催化分{catalyst_score:.0f}"})
|
boosts.append({"label": "新闻催化加权", "value": "+3%", "reason": catalyst_reasons[0] if catalyst_reasons else f"催化分{catalyst_score:.0f}"})
|
||||||
|
|
||||||
flow_multiplier = _flow_confirmation_multiplier(stock, hot_theme_match, market_temp)
|
flow_multiplier = 1.0
|
||||||
final_score *= flow_multiplier
|
|
||||||
if flow_multiplier > 1:
|
|
||||||
boosts.append({"label": "资金主线共振", "value": f"+{round((flow_multiplier - 1) * 100)}%", "reason": "资金、量能与主线同向"})
|
|
||||||
elif flow_multiplier < 1:
|
|
||||||
boosts.append({"label": "资金确认不足", "value": f"-{round((1 - flow_multiplier) * 100)}%", "reason": "资金或主线承接不足"})
|
|
||||||
|
|
||||||
theme_penalty = 1.0
|
theme_penalty = 1.0
|
||||||
if not hot_theme_match:
|
if not hot_theme_match:
|
||||||
@ -1132,28 +1137,10 @@ async def _build_recommendations(
|
|||||||
final_score *= theme_penalty
|
final_score *= theme_penalty
|
||||||
|
|
||||||
signal_matches_profile = bool(signal_priority and signal_name in signal_priority[:4])
|
signal_matches_profile = bool(signal_priority and signal_name in signal_priority[:4])
|
||||||
profile_multiplier = 1.0
|
|
||||||
if signal_type != EntrySignal.NONE and signal_priority:
|
|
||||||
priority_rank = signal_priority.index(signal_type.value)
|
|
||||||
if priority_rank == 0:
|
|
||||||
profile_multiplier = 1.08
|
|
||||||
final_score *= profile_multiplier
|
|
||||||
elif priority_rank == 1:
|
|
||||||
profile_multiplier = 1.04
|
|
||||||
final_score *= profile_multiplier
|
|
||||||
elif priority_rank >= 3:
|
|
||||||
profile_multiplier = 0.94
|
|
||||||
final_score *= profile_multiplier
|
|
||||||
if profile_multiplier != 1.0:
|
|
||||||
boosts.append({
|
|
||||||
"label": "策略匹配度",
|
|
||||||
"value": f"{'+' if profile_multiplier > 1 else '-'}{round(abs(profile_multiplier - 1) * 100)}%",
|
|
||||||
"reason": f"{signal_name} 与今日策略优先级匹配",
|
|
||||||
})
|
|
||||||
|
|
||||||
pe = stock.get("pe")
|
pe = stock.get("pe")
|
||||||
pb = stock.get("pb")
|
pb = stock.get("pb")
|
||||||
valuation_score = _score_valuation(pe, pb)
|
valuation_score = 50 # 不再计算估值分,短线无意义
|
||||||
|
|
||||||
level = _score_to_level(final_score)
|
level = _score_to_level(final_score)
|
||||||
signal = "HOLD"
|
signal = "HOLD"
|
||||||
|
|||||||
168
backend/app/engine/trigger_monitor.py
Normal file
168
backend/app/engine/trigger_monitor.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
"""盘中买点触发监控
|
||||||
|
|
||||||
|
从当日埋伏池加载标的,每分钟用腾讯批量行情检查是否命中买入条件。
|
||||||
|
命中后通过飞书 webhook 推送告警,每只票每天最多触发一次。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.data.cache import cache
|
||||||
|
from app.config import is_trading_hours
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_triggers():
|
||||||
|
"""主入口:检查当日埋伏池是否有买点触发。"""
|
||||||
|
if not is_trading_hours():
|
||||||
|
return
|
||||||
|
|
||||||
|
pool = await _load_today_ambush_pool()
|
||||||
|
if not pool:
|
||||||
|
return
|
||||||
|
|
||||||
|
from app.data.tencent_client import get_realtime_quotes_batch
|
||||||
|
|
||||||
|
codes = [item["ts_code"] for item in pool]
|
||||||
|
quotes = await get_realtime_quotes_batch(codes)
|
||||||
|
|
||||||
|
for item in pool:
|
||||||
|
ts_code = item["ts_code"]
|
||||||
|
quote = quotes.get(ts_code)
|
||||||
|
if not quote or quote.price <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 去重:每只票每天最多触发一次
|
||||||
|
dedup_key = f"trigger_fired:{ts_code}:{datetime.now().strftime('%Y%m%d')}"
|
||||||
|
if cache.get(dedup_key):
|
||||||
|
continue
|
||||||
|
|
||||||
|
trigger_type = _check_trigger_condition(item, quote)
|
||||||
|
if trigger_type:
|
||||||
|
cache.set(dedup_key, True, 86400)
|
||||||
|
await _send_trigger_alert(item, quote, trigger_type)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_trigger_condition(item: dict, quote) -> str | None:
|
||||||
|
"""检查是否命中买入条件。返回触发类型或 None。"""
|
||||||
|
entry_price = item.get("entry_price")
|
||||||
|
entry_signal_type = item.get("entry_signal_type", "")
|
||||||
|
volume_ratio = quote.volume_ratio or 0
|
||||||
|
|
||||||
|
if not entry_price or entry_price <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
price = quote.price
|
||||||
|
|
||||||
|
# 突破型:价格站上 entry_price 且量比 > 1.5
|
||||||
|
if entry_signal_type in ("breakout", "breakout_confirm"):
|
||||||
|
if price >= entry_price and volume_ratio >= 1.5:
|
||||||
|
return "突破确认"
|
||||||
|
|
||||||
|
# 回踩型:价格回到 entry_price 附近(±2%) 且缩量
|
||||||
|
elif entry_signal_type == "pullback":
|
||||||
|
if abs(price - entry_price) / entry_price <= 0.02 and volume_ratio < 0.8:
|
||||||
|
return "回踩到位"
|
||||||
|
|
||||||
|
# 启动型:价格突破 entry_price 且量比 > 1.2
|
||||||
|
elif entry_signal_type in ("launch", "reversal"):
|
||||||
|
if price >= entry_price and volume_ratio >= 1.2:
|
||||||
|
return "启动放量"
|
||||||
|
|
||||||
|
# 通用:价格站上 entry_price 且量比 > 1.5
|
||||||
|
else:
|
||||||
|
if price >= entry_price and volume_ratio >= 1.5:
|
||||||
|
return "放量突破"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_today_ambush_pool() -> list[dict]:
|
||||||
|
"""从数据库加载当日可操作/重点关注的埋伏标的。"""
|
||||||
|
cache_key = "trigger_pool:today"
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
try:
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.db.database import get_db
|
||||||
|
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT ts_code, name, entry_price, stop_loss, target_price, "
|
||||||
|
"entry_signal_type, action_plan, sector, score "
|
||||||
|
"FROM recommendations "
|
||||||
|
"WHERE date(created_at) = :today "
|
||||||
|
"AND action_plan IN ('可操作', '重点关注') "
|
||||||
|
"ORDER BY score DESC LIMIT 10"
|
||||||
|
),
|
||||||
|
{"today": today},
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
pool = [
|
||||||
|
{
|
||||||
|
"ts_code": r._mapping["ts_code"],
|
||||||
|
"name": r._mapping["name"],
|
||||||
|
"entry_price": r._mapping["entry_price"],
|
||||||
|
"stop_loss": r._mapping["stop_loss"],
|
||||||
|
"target_price": r._mapping["target_price"],
|
||||||
|
"entry_signal_type": r._mapping["entry_signal_type"] or "",
|
||||||
|
"action_plan": r._mapping["action_plan"],
|
||||||
|
"sector": r._mapping["sector"] or "",
|
||||||
|
"score": r._mapping["score"] or 0,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
# 缓存 5 分钟,避免每分钟查 DB
|
||||||
|
cache.set(cache_key, pool, 300)
|
||||||
|
return pool
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"加载埋伏池失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_trigger_alert(item: dict, quote, trigger_type: str):
|
||||||
|
"""通过飞书发送买点触发告警。"""
|
||||||
|
from app.notifications.feishu import send_feishu_alert
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
if not settings.recommendation_push_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
name = item["name"]
|
||||||
|
ts_code = item["ts_code"]
|
||||||
|
sector = item.get("sector", "")
|
||||||
|
entry_price = item.get("entry_price", 0)
|
||||||
|
target_price = item.get("target_price")
|
||||||
|
stop_loss = item.get("stop_loss")
|
||||||
|
score = item.get("score", 0)
|
||||||
|
|
||||||
|
price_str = f"{quote.price:.2f}"
|
||||||
|
pct_str = f"{quote.pct_chg:+.1f}%"
|
||||||
|
vr_str = f"{quote.volume_ratio:.1f}" if quote.volume_ratio else "-"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"🎯 买点触发: {name} ({ts_code})",
|
||||||
|
f"触发类型: {trigger_type}",
|
||||||
|
f"当前价: {price_str} ({pct_str})",
|
||||||
|
f"量比: {vr_str}",
|
||||||
|
f"板块: {sector}",
|
||||||
|
f"评分: {score:.0f}",
|
||||||
|
f"入场价: {entry_price:.2f}" if entry_price else "",
|
||||||
|
f"目标价: {target_price:.2f}" if target_price else "",
|
||||||
|
f"止损价: {stop_loss:.2f}" if stop_loss else "",
|
||||||
|
f"建议: {item.get('action_plan', '观察')}",
|
||||||
|
]
|
||||||
|
message = "\n".join(line for line in lines if line)
|
||||||
|
|
||||||
|
await send_feishu_alert(
|
||||||
|
source="trigger_monitor",
|
||||||
|
message=f"买点触发: {name} {trigger_type}",
|
||||||
|
detail=message,
|
||||||
|
level="info",
|
||||||
|
)
|
||||||
|
logger.info(f"买点触发告警已发送: {name} {trigger_type} @ {quote.price}")
|
||||||
Binary file not shown.
@ -1,351 +0,0 @@
|
|||||||
"""市场作战面板
|
|
||||||
|
|
||||||
把市场温度、板块、推荐和历史跟踪结果汇总成每天可执行的策略视图。
|
|
||||||
规则层保证稳定输出,LLM 层负责补充解释和迭代建议。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.config import settings, should_prefer_realtime_today, today_trade_date
|
|
||||||
from app.data.models import (
|
|
||||||
MarketTemperature,
|
|
||||||
Recommendation,
|
|
||||||
SectorInfo,
|
|
||||||
StrategyBoard,
|
|
||||||
StrategyFocus,
|
|
||||||
StrategySectorFocus,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _sector_pct(sector: SectorInfo) -> float:
|
|
||||||
return float(sector.realtime_pct_change if sector.realtime_pct_change is not None else sector.pct_change)
|
|
||||||
|
|
||||||
|
|
||||||
def _sector_turnover(sector: SectorInfo) -> float:
|
|
||||||
return float(sector.realtime_turnover_rate if sector.realtime_turnover_rate is not None else sector.turnover_avg)
|
|
||||||
|
|
||||||
|
|
||||||
def _sector_up_count(sector: SectorInfo) -> int:
|
|
||||||
return int(sector.realtime_up_count if sector.realtime_up_count is not None else 0)
|
|
||||||
|
|
||||||
|
|
||||||
def _sector_down_count(sector: SectorInfo) -> int:
|
|
||||||
return int(sector.realtime_down_count if sector.realtime_down_count is not None else 0)
|
|
||||||
|
|
||||||
|
|
||||||
def _sector_breadth(sector: SectorInfo) -> int:
|
|
||||||
return _sector_up_count(sector) - _sector_down_count(sector)
|
|
||||||
|
|
||||||
|
|
||||||
def _sector_strength_score(sector: SectorInfo) -> float:
|
|
||||||
strength = _sector_pct(sector) * 12
|
|
||||||
strength += min(max(_sector_breadth(sector), -30), 30) * 0.6
|
|
||||||
strength += min(_sector_turnover(sector), 12) * 1.5
|
|
||||||
strength += min(sector.limit_up_count, 8) * 2.5
|
|
||||||
strength += min(sector.days_continuous, 5) * 1.5
|
|
||||||
return round(strength, 1)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
raw_trade_date = market_temp.trade_date if market_temp else ""
|
|
||||||
prefer_realtime = should_prefer_realtime_today(raw_trade_date)
|
|
||||||
trade_date = today_trade_date() if prefer_realtime else raw_trade_date
|
|
||||||
data_mode = "realtime_today" if prefer_realtime else "daily_snapshot"
|
|
||||||
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 == "重点关注"]
|
|
||||||
strong_sectors = [
|
|
||||||
s for s in sectors[:5]
|
|
||||||
if _sector_pct(s) >= 1.5 and (_sector_breadth(s) > 0 or s.limit_up_count >= 2)
|
|
||||||
]
|
|
||||||
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)
|
|
||||||
mode_prefix = "今日实时视角:" if prefer_realtime else ""
|
|
||||||
|
|
||||||
summary = (
|
|
||||||
f"{mode_prefix}{market_regime},风险等级{risk_level}。"
|
|
||||||
f"当前 {len(recommendations)} 只入选,其中 {len(actionable)} 只可操作、"
|
|
||||||
f"{len(watch)} 只重点关注,平均分 {avg_score}。"
|
|
||||||
f"{'主线活跃板块 ' + ' / '.join(s.sector_name for s in strong_sectors[:3]) + '。' if strong_sectors else '板块尚未形成强共振,优先等确认。'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
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,
|
|
||||||
data_mode=data_mode,
|
|
||||||
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:
|
|
||||||
top = sectors[:5]
|
|
||||||
strong = [s for s in top if _sector_pct(s) >= 2 and _sector_breadth(s) > 0]
|
|
||||||
active = [s for s in top if _sector_pct(s) >= 1 and (_sector_turnover(s) >= 2 or s.limit_up_count >= 2)]
|
|
||||||
end_stage = [s for s in top if s.stage == "end" and _sector_pct(s) > 0]
|
|
||||||
|
|
||||||
if temp >= 65 and len(strong) >= 2:
|
|
||||||
return "顺主线做强势确认"
|
|
||||||
if temp >= 55 and active:
|
|
||||||
return "围绕强板块做回踩确认"
|
|
||||||
if temp < 45 or end_stage:
|
|
||||||
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]
|
|
||||||
sector_pct = _sector_pct(main)
|
|
||||||
breadth = _sector_breadth(main)
|
|
||||||
turnover = _sector_turnover(main)
|
|
||||||
focus.append(StrategyFocus(
|
|
||||||
label=f"{main.sector_name} 主线跟踪",
|
|
||||||
description=(
|
|
||||||
f"当前涨幅 {sector_pct:+.2f}% ,广度 {breadth:+d},换手 {turnover:.1f}% ,"
|
|
||||||
f"阶段 {main.stage},优先确认资金是否延续。"
|
|
||||||
),
|
|
||||||
))
|
|
||||||
|
|
||||||
if len(sectors) > 1:
|
|
||||||
runner_up = sectors[1]
|
|
||||||
focus.append(StrategyFocus(
|
|
||||||
label=f"{runner_up.sector_name} 轮动监控",
|
|
||||||
description=(
|
|
||||||
f"强度分 {_sector_strength_score(runner_up)},"
|
|
||||||
f"若涨幅继续扩大且广度转强,可切入今日第二梯队。"
|
|
||||||
),
|
|
||||||
))
|
|
||||||
|
|
||||||
if temp < 45:
|
|
||||||
focus.append(StrategyFocus(
|
|
||||||
label="防守优先",
|
|
||||||
description="市场温度不足,推荐只作为观察池,不宜扩大仓位。",
|
|
||||||
))
|
|
||||||
elif sectors and _sector_pct(sectors[0]) < 1:
|
|
||||||
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(sector),
|
|
||||||
limit_up_count=sector.limit_up_count,
|
|
||||||
turnover_rate=_sector_turnover(sector),
|
|
||||||
up_count=_sector_up_count(sector),
|
|
||||||
down_count=_sector_down_count(sector),
|
|
||||||
data_mode=sector.data_mode,
|
|
||||||
view=(
|
|
||||||
f"{stage_view};当前涨幅 {_sector_pct(sector):+.2f}%"
|
|
||||||
+ (
|
|
||||||
f",上涨/下跌 {_sector_up_count(sector)}/{_sector_down_count(sector)}"
|
|
||||||
if sector.is_realtime else ""
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_avoid_rules(
|
|
||||||
temp: float, sectors: list[SectorInfo], recommendations: list[Recommendation]
|
|
||||||
) -> list[str]:
|
|
||||||
rules = []
|
|
||||||
top = sectors[:5]
|
|
||||||
if temp < 45:
|
|
||||||
rules.append("市场温度低于45时,不追突破首日,只等次日确认或回踩。")
|
|
||||||
if any(s.stage == "end" for s in top):
|
|
||||||
rules.append("板块进入末期时,降低同板块追高标的权重。")
|
|
||||||
if top and _sector_pct(top[0]) < 1:
|
|
||||||
rules.append("主线板块涨幅不足1%时,不把局部异动当成全面进攻信号。")
|
|
||||||
if any(_sector_breadth(s) < 0 for s in top[:3] if s.is_realtime):
|
|
||||||
rules.append("板块涨幅与广度背离时,优先回避后排补涨和冲高追单。")
|
|
||||||
if any(_sector_turnover(s) > 8 and s.stage in ("late", "end") for s in top):
|
|
||||||
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 ""
|
|
||||||
@ -1,421 +1,25 @@
|
|||||||
"""策略配置中心
|
"""策略配置 stub
|
||||||
|
|
||||||
把可迭代的策略参数和 Prompt 版本持久化到数据库。
|
策略参数现在直接写在 strategy_selector.py 中。
|
||||||
代码里的默认策略只作为兜底;一旦数据库有激活配置,下一轮扫描直接读取配置。
|
此模块仅保留 ensure_default_configs (main.py 启动调用) 和
|
||||||
|
load_active_strategy_profile (selector 调用) 的兼容接口。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from app.db.database import get_db
|
|
||||||
from app.db import tables
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CONFIG_FIELDS = {
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"entry_signal_priority",
|
|
||||||
"score_weights",
|
|
||||||
"min_score",
|
|
||||||
"buy_threshold",
|
|
||||||
"max_position_pct",
|
|
||||||
"allow_trading",
|
|
||||||
"actionable_limit",
|
|
||||||
"watch_limit",
|
|
||||||
"target_focus_sectors",
|
|
||||||
"market_stance",
|
|
||||||
"decision_note",
|
|
||||||
"notes",
|
|
||||||
}
|
|
||||||
|
|
||||||
PROMPT_DEFAULT_KEYS = {
|
async def ensure_default_configs() -> None:
|
||||||
"stock_prefilter": "STOCK_PREFILTER_PROMPT",
|
"""兼容接口 — 启动时不再需要写策略配置到数据库。"""
|
||||||
"single_stock_analysis": "SINGLE_STOCK_ANALYSIS_PROMPT",
|
pass
|
||||||
"strategy_iteration": "STRATEGY_ITERATION_PROMPT",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def profile_to_config(profile) -> dict[str, Any]:
|
|
||||||
data = profile.model_dump() if hasattr(profile, "model_dump") else dict(profile)
|
|
||||||
return {key: data[key] for key in CONFIG_FIELDS if key in data}
|
|
||||||
|
|
||||||
|
|
||||||
def apply_config_to_profile(profile, config: dict[str, Any] | None, generated_by: str = "config"):
|
|
||||||
if not config:
|
|
||||||
return profile
|
|
||||||
updated = profile.model_copy(deep=True)
|
|
||||||
for key, value in config.items():
|
|
||||||
if key in CONFIG_FIELDS and hasattr(updated, key):
|
|
||||||
setattr(updated, key, value)
|
|
||||||
updated.generated_by = generated_by
|
|
||||||
return updated
|
|
||||||
|
|
||||||
|
|
||||||
async def load_active_strategy_profile(profile):
|
async def load_active_strategy_profile(profile):
|
||||||
row = await _load_active_strategy_row(profile.strategy_id)
|
"""直接返回规则 profile,不再从数据库加载配置覆盖。"""
|
||||||
if not row:
|
return profile
|
||||||
return profile
|
|
||||||
config = _json_loads(row["config_json"], {})
|
|
||||||
updated = apply_config_to_profile(profile, config, generated_by=f"config:v{row['version']}")
|
|
||||||
updated.feedback_applied = True
|
|
||||||
updated.feedback_notes = [
|
|
||||||
f"策略配置版本 v{row['version']} ({row['source']}) 已生效",
|
|
||||||
row["change_reason"] or "使用配置中心激活版本",
|
|
||||||
]
|
|
||||||
return updated
|
|
||||||
|
|
||||||
|
|
||||||
async def get_active_strategy_configs() -> list[dict]:
|
|
||||||
await ensure_default_configs()
|
|
||||||
async with get_db() as db:
|
|
||||||
result = await db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT * FROM strategy_configs "
|
|
||||||
"WHERE is_active = 1 "
|
|
||||||
"ORDER BY strategy_id ASC, version DESC"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return [_format_strategy_row(row._mapping) for row in result.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_recent_config_changes(limit: int = 20) -> list[dict]:
|
|
||||||
async with get_db() as db:
|
|
||||||
result = await db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT * FROM strategy_config_changes "
|
|
||||||
"ORDER BY id DESC LIMIT :limit"
|
|
||||||
),
|
|
||||||
{"limit": limit},
|
|
||||||
)
|
|
||||||
return [_format_change_row(row._mapping) for row in result.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_active_prompt_configs() -> list[dict]:
|
|
||||||
await ensure_default_configs()
|
|
||||||
async with get_db() as db:
|
|
||||||
result = await db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT * FROM prompt_configs "
|
|
||||||
"WHERE is_active = 1 "
|
|
||||||
"ORDER BY prompt_key ASC, version DESC"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return [_format_prompt_row(row._mapping) for row in result.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_prompt_content(prompt_key: str, default: str) -> str:
|
async def get_prompt_content(prompt_key: str, default: str) -> str:
|
||||||
async with get_db() as db:
|
"""直接返回默认 prompt,不再从数据库加载。"""
|
||||||
result = await db.execute(
|
return default
|
||||||
text(
|
|
||||||
"SELECT content FROM prompt_configs "
|
|
||||||
"WHERE prompt_key = :key AND is_active = 1 "
|
|
||||||
"ORDER BY version DESC LIMIT 1"
|
|
||||||
),
|
|
||||||
{"key": prompt_key},
|
|
||||||
)
|
|
||||||
row = result.fetchone()
|
|
||||||
return str(row._mapping["content"]) if row else default
|
|
||||||
|
|
||||||
|
|
||||||
async def create_active_strategy_config(
|
|
||||||
strategy_id: str,
|
|
||||||
config: dict[str, Any],
|
|
||||||
*,
|
|
||||||
source: str,
|
|
||||||
reason: str,
|
|
||||||
evidence: dict[str, Any] | None = None,
|
|
||||||
change_type: str = "manual",
|
|
||||||
) -> dict:
|
|
||||||
"""写入一个新的激活策略配置版本,并记录变更。"""
|
|
||||||
async with get_db() as db:
|
|
||||||
base = await _load_active_strategy_row(strategy_id, db=db)
|
|
||||||
version = int(base["version"]) + 1 if base else 1
|
|
||||||
before = _json_loads(base["config_json"], {}) if base else {}
|
|
||||||
diff = _build_diff(before, config)
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
text("UPDATE strategy_configs SET is_active = 0 WHERE strategy_id = :sid"),
|
|
||||||
{"sid": strategy_id},
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
tables.strategy_configs_table.insert().values(
|
|
||||||
strategy_id=strategy_id,
|
|
||||||
version=version,
|
|
||||||
config_json=json.dumps(config, ensure_ascii=False),
|
|
||||||
is_active=True,
|
|
||||||
source=source,
|
|
||||||
change_reason=reason,
|
|
||||||
evidence_json=json.dumps(evidence or {}, ensure_ascii=False),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
tables.strategy_config_changes_table.insert().values(
|
|
||||||
change_type=change_type,
|
|
||||||
status="applied",
|
|
||||||
strategy_id=strategy_id,
|
|
||||||
base_version=int(base["version"]) if base else 0,
|
|
||||||
new_version=version,
|
|
||||||
diff_json=json.dumps(diff, ensure_ascii=False),
|
|
||||||
evidence_json=json.dumps(evidence or {}, ensure_ascii=False),
|
|
||||||
reason=reason,
|
|
||||||
applied_at=datetime.now(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
row = await _load_active_strategy_row(strategy_id)
|
|
||||||
return _format_strategy_row(row)
|
|
||||||
|
|
||||||
|
|
||||||
async def rollback_strategy_config(strategy_id: str) -> dict:
|
|
||||||
"""回滚到当前策略的上一个版本。"""
|
|
||||||
async with get_db() as db:
|
|
||||||
active = await _load_active_strategy_row(strategy_id, db=db)
|
|
||||||
if not active:
|
|
||||||
raise ValueError("当前策略没有激活配置")
|
|
||||||
result = await db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT * FROM strategy_configs "
|
|
||||||
"WHERE strategy_id = :sid AND version < :version "
|
|
||||||
"ORDER BY version DESC LIMIT 1"
|
|
||||||
),
|
|
||||||
{"sid": strategy_id, "version": active["version"]},
|
|
||||||
)
|
|
||||||
previous_row = result.fetchone()
|
|
||||||
if not previous_row:
|
|
||||||
raise ValueError("没有可回滚的上一版本")
|
|
||||||
current = active
|
|
||||||
previous = previous_row._mapping
|
|
||||||
await db.execute(
|
|
||||||
text("UPDATE strategy_configs SET is_active = 0 WHERE strategy_id = :sid"),
|
|
||||||
{"sid": strategy_id},
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
text("UPDATE strategy_configs SET is_active = 1, source = 'rollback' WHERE id = :id"),
|
|
||||||
{"id": previous["id"]},
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
tables.strategy_config_changes_table.insert().values(
|
|
||||||
change_type="rollback",
|
|
||||||
status="applied",
|
|
||||||
strategy_id=strategy_id,
|
|
||||||
base_version=int(current["version"]),
|
|
||||||
new_version=int(previous["version"]),
|
|
||||||
diff_json=json.dumps(
|
|
||||||
_build_diff(_json_loads(current["config_json"], {}), _json_loads(previous["config_json"], {})),
|
|
||||||
ensure_ascii=False,
|
|
||||||
),
|
|
||||||
reason=f"回滚 {strategy_id} 到 v{previous['version']}",
|
|
||||||
applied_at=datetime.now(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
row = await _load_active_strategy_row(strategy_id)
|
|
||||||
return _format_strategy_row(row)
|
|
||||||
|
|
||||||
|
|
||||||
async def ensure_default_configs() -> None:
|
|
||||||
"""首次启动时把代码默认策略和默认 Prompt 种子写入数据库。"""
|
|
||||||
from app.llm.strategy_selector import get_strategy_profile_by_id
|
|
||||||
from app.llm import prompts
|
|
||||||
|
|
||||||
strategy_ids = ["breakout_attack", "pullback_rotation", "launch_probe", "defensive_watch"]
|
|
||||||
async with get_db() as db:
|
|
||||||
for strategy_id in strategy_ids:
|
|
||||||
count = (await db.execute(
|
|
||||||
text("SELECT COUNT(*) FROM strategy_configs WHERE strategy_id = :sid"),
|
|
||||||
{"sid": strategy_id},
|
|
||||||
)).scalar() or 0
|
|
||||||
if count:
|
|
||||||
continue
|
|
||||||
profile = get_strategy_profile_by_id(strategy_id)
|
|
||||||
await db.execute(
|
|
||||||
tables.strategy_configs_table.insert().values(
|
|
||||||
strategy_id=strategy_id,
|
|
||||||
version=1,
|
|
||||||
config_json=json.dumps(profile_to_config(profile), ensure_ascii=False),
|
|
||||||
is_active=True,
|
|
||||||
source="default_seed",
|
|
||||||
change_reason="初始化默认策略配置",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt_defaults = {
|
|
||||||
"stock_prefilter": getattr(prompts, "STOCK_PREFILTER_PROMPT", ""),
|
|
||||||
"single_stock_analysis": getattr(prompts, "SINGLE_STOCK_ANALYSIS_PROMPT", ""),
|
|
||||||
"strategy_iteration": getattr(prompts, "STRATEGY_ITERATION_PROMPT", ""),
|
|
||||||
}
|
|
||||||
for prompt_key, content in prompt_defaults.items():
|
|
||||||
if not content:
|
|
||||||
continue
|
|
||||||
count = (await db.execute(
|
|
||||||
text("SELECT COUNT(*) FROM prompt_configs WHERE prompt_key = :key"),
|
|
||||||
{"key": prompt_key},
|
|
||||||
)).scalar() or 0
|
|
||||||
if count:
|
|
||||||
continue
|
|
||||||
await db.execute(
|
|
||||||
tables.prompt_configs_table.insert().values(
|
|
||||||
prompt_key=prompt_key,
|
|
||||||
version=1,
|
|
||||||
content=content,
|
|
||||||
is_active=True,
|
|
||||||
source="default_seed",
|
|
||||||
change_reason="初始化默认 Prompt 配置",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def maybe_auto_apply_review_adjustment(report: dict) -> dict | None:
|
|
||||||
"""根据复盘报告做小幅自动配置调整。
|
|
||||||
|
|
||||||
大幅结构调整仍只进入报告建议,不自动改配置。
|
|
||||||
"""
|
|
||||||
sample_size = int(report.get("sample_size") or 0)
|
|
||||||
if sample_size < 10:
|
|
||||||
return None
|
|
||||||
if await _has_auto_change_today():
|
|
||||||
return None
|
|
||||||
|
|
||||||
for suggestion in report.get("adjustment_suggestions", []) or []:
|
|
||||||
strategy_id = suggestion.get("target", "")
|
|
||||||
if strategy_id not in {"breakout_attack", "pullback_rotation", "launch_probe", "defensive_watch"}:
|
|
||||||
continue
|
|
||||||
active = await _load_active_strategy_row(strategy_id)
|
|
||||||
if not active:
|
|
||||||
continue
|
|
||||||
config = _json_loads(active["config_json"], {})
|
|
||||||
changed = _apply_small_adjustment(config, suggestion.get("action", ""))
|
|
||||||
if not changed:
|
|
||||||
continue
|
|
||||||
evidence = {
|
|
||||||
"sample_size": sample_size,
|
|
||||||
"summary": report.get("summary", ""),
|
|
||||||
"suggestion": suggestion,
|
|
||||||
}
|
|
||||||
return await create_active_strategy_config(
|
|
||||||
strategy_id,
|
|
||||||
config,
|
|
||||||
source="auto_review",
|
|
||||||
reason=suggestion.get("reason", "复盘触发小幅自动配置调整"),
|
|
||||||
evidence=evidence,
|
|
||||||
change_type="auto_applied",
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _load_active_strategy_row(strategy_id: str, db=None):
|
|
||||||
own_session = db is None
|
|
||||||
if own_session:
|
|
||||||
async with get_db() as session:
|
|
||||||
return await _load_active_strategy_row(strategy_id, db=session)
|
|
||||||
result = await db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT * FROM strategy_configs "
|
|
||||||
"WHERE strategy_id = :sid AND is_active = 1 "
|
|
||||||
"ORDER BY version DESC LIMIT 1"
|
|
||||||
),
|
|
||||||
{"sid": strategy_id},
|
|
||||||
)
|
|
||||||
row = result.fetchone()
|
|
||||||
return row._mapping if row else None
|
|
||||||
|
|
||||||
|
|
||||||
async def _has_auto_change_today() -> bool:
|
|
||||||
async with get_db() as db:
|
|
||||||
count = (await db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT COUNT(*) FROM strategy_config_changes "
|
|
||||||
"WHERE change_type = 'auto_applied' "
|
|
||||||
"AND date(created_at) = date('now', 'localtime')"
|
|
||||||
)
|
|
||||||
)).scalar() or 0
|
|
||||||
return count > 0
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_small_adjustment(config: dict[str, Any], action: str) -> bool:
|
|
||||||
if action == "tighten":
|
|
||||||
config["buy_threshold"] = min(float(config.get("buy_threshold", 60)) + 1, 80)
|
|
||||||
config["max_position_pct"] = max(float(config.get("max_position_pct", 10)) - 5, 0)
|
|
||||||
config["actionable_limit"] = max(int(config.get("actionable_limit", 1)) - 1, 0)
|
|
||||||
return True
|
|
||||||
if action == "promote":
|
|
||||||
config["buy_threshold"] = max(float(config.get("buy_threshold", 60)) - 1, float(config.get("min_score", 0)))
|
|
||||||
config["watch_limit"] = min(int(config.get("watch_limit", 3)) + 1, 8)
|
|
||||||
return True
|
|
||||||
if action == "reduce":
|
|
||||||
config["buy_threshold"] = min(float(config.get("buy_threshold", 60)) + 1, 80)
|
|
||||||
config["watch_limit"] = max(int(config.get("watch_limit", 3)) - 1, 1)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _build_diff(before: dict[str, Any], after: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
||||||
diff: dict[str, dict[str, Any]] = {}
|
|
||||||
for key in sorted(set(before) | set(after)):
|
|
||||||
if before.get(key) != after.get(key):
|
|
||||||
diff[key] = {"from": before.get(key), "to": after.get(key)}
|
|
||||||
return diff
|
|
||||||
|
|
||||||
|
|
||||||
def _json_loads(value: str | None, default):
|
|
||||||
try:
|
|
||||||
return json.loads(value or "")
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _format_strategy_row(row) -> dict:
|
|
||||||
if not row:
|
|
||||||
return {}
|
|
||||||
return {
|
|
||||||
"id": row["id"],
|
|
||||||
"strategy_id": row["strategy_id"],
|
|
||||||
"version": row["version"],
|
|
||||||
"config": _json_loads(row["config_json"], {}),
|
|
||||||
"is_active": bool(row["is_active"]),
|
|
||||||
"source": row["source"] or "",
|
|
||||||
"change_reason": row["change_reason"] or "",
|
|
||||||
"evidence": _json_loads(row["evidence_json"], {}),
|
|
||||||
"effective_from": str(row["effective_from"] or ""),
|
|
||||||
"created_at": str(row["created_at"] or ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _format_prompt_row(row) -> dict:
|
|
||||||
return {
|
|
||||||
"id": row["id"],
|
|
||||||
"prompt_key": row["prompt_key"],
|
|
||||||
"version": row["version"],
|
|
||||||
"content": row["content"],
|
|
||||||
"is_active": bool(row["is_active"]),
|
|
||||||
"source": row["source"] or "",
|
|
||||||
"change_reason": row["change_reason"] or "",
|
|
||||||
"evidence": _json_loads(row["evidence_json"], {}),
|
|
||||||
"created_at": str(row["created_at"] or ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _format_change_row(row) -> dict:
|
|
||||||
return {
|
|
||||||
"id": row["id"],
|
|
||||||
"change_type": row["change_type"],
|
|
||||||
"status": row["status"],
|
|
||||||
"strategy_id": row["strategy_id"] or "",
|
|
||||||
"prompt_key": row["prompt_key"] or "",
|
|
||||||
"base_version": row["base_version"] or 0,
|
|
||||||
"new_version": row["new_version"] or 0,
|
|
||||||
"diff": _json_loads(row["diff_json"], {}),
|
|
||||||
"evidence": _json_loads(row["evidence_json"], {}),
|
|
||||||
"reason": row["reason"] or "",
|
|
||||||
"created_at": str(row["created_at"] or ""),
|
|
||||||
"applied_at": str(row["applied_at"] or ""),
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,576 +0,0 @@
|
|||||||
"""策略复盘迭代 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,
|
|
||||||
apply_auto_config: bool = False,
|
|
||||||
) -> dict:
|
|
||||||
rows = await _load_recent_tracking(limit)
|
|
||||||
rule_report = _build_rule_report(rows)
|
|
||||||
|
|
||||||
auto_change = None
|
|
||||||
if apply_auto_config:
|
|
||||||
from app.llm.strategy_config import maybe_auto_apply_review_adjustment
|
|
||||||
try:
|
|
||||||
auto_change = await maybe_auto_apply_review_adjustment(rule_report)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"自动策略配置调整失败: {e}")
|
|
||||||
if auto_change:
|
|
||||||
rule_report["auto_config_change"] = auto_change
|
|
||||||
rule_report["generated_by"] = "rules+auto_config"
|
|
||||||
|
|
||||||
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 build_strategy_feedback_controls(limit: int = 50) -> dict:
|
|
||||||
rows = await _load_recent_tracking(limit)
|
|
||||||
report = _build_rule_report(rows)
|
|
||||||
return _derive_feedback_controls(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")
|
|
||||||
r_capital_score = _column_or_default(rec_columns, "capital_score", "0", "r")
|
|
||||||
r_recall_tags = _column_or_default(rec_columns, "recall_tags", "'[]'", "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_capital_score} AS capital_score, {r_position_score} AS position_score, "
|
|
||||||
f"{r_lifecycle_status} AS lifecycle_status, {r_recall_tags} AS recall_tags, "
|
|
||||||
"r.entry_price, r.target_price, r.stop_loss, 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": ["样本不足,先积累推荐生命周期数据。"],
|
|
||||||
"review_windows": [],
|
|
||||||
"failure_cases": [],
|
|
||||||
"success_patterns": [],
|
|
||||||
"adjustment_suggestions": [],
|
|
||||||
"agent_patch_prompts": [],
|
|
||||||
"auto_config_change": None,
|
|
||||||
"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))
|
|
||||||
review_windows = _build_review_windows(tracked_rows)
|
|
||||||
failure_cases = _build_failure_cases(tracked_rows)
|
|
||||||
success_patterns = _build_success_patterns(tracked_rows)
|
|
||||||
patch_prompts = _build_agent_patch_prompts(
|
|
||||||
strategy_stats=strategy_stats,
|
|
||||||
signal_stats=signal_stats,
|
|
||||||
failure_patterns=failure_patterns,
|
|
||||||
failure_cases=failure_cases,
|
|
||||||
sample_size=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,
|
|
||||||
"review_windows": review_windows,
|
|
||||||
"failure_cases": failure_cases,
|
|
||||||
"success_patterns": success_patterns,
|
|
||||||
"adjustment_suggestions": suggestions,
|
|
||||||
"agent_patch_prompts": patch_prompts,
|
|
||||||
"auto_config_change": None,
|
|
||||||
"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]
|
|
||||||
|
|
||||||
|
|
||||||
def _build_review_windows(rows: list[dict]) -> list[dict]:
|
|
||||||
windows = []
|
|
||||||
for days in [3, 5, 10]:
|
|
||||||
items = [
|
|
||||||
r for r in rows
|
|
||||||
if int(r.get("days_since_recommendation") or 0) >= days
|
|
||||||
]
|
|
||||||
if not items:
|
|
||||||
windows.append({
|
|
||||||
"window_days": days,
|
|
||||||
"count": 0,
|
|
||||||
"win_rate": 0,
|
|
||||||
"avg_return": 0,
|
|
||||||
"hit_target_rate": 0,
|
|
||||||
"hit_stop_rate": 0,
|
|
||||||
"avg_max_return": 0,
|
|
||||||
"avg_max_drawdown": 0,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
wins = sum(1 for r in items if (r.get("pct_from_entry") or 0) > 0)
|
|
||||||
hit_target = sum(1 for r in items if r.get("close_reason") == "hit_target")
|
|
||||||
hit_stop = sum(1 for r in items if r.get("close_reason") == "hit_stop_loss")
|
|
||||||
count = len(items)
|
|
||||||
windows.append({
|
|
||||||
"window_days": days,
|
|
||||||
"count": count,
|
|
||||||
"win_rate": round(wins / count * 100, 1),
|
|
||||||
"avg_return": _avg([r.get("pct_from_entry") for r in items]),
|
|
||||||
"hit_target_rate": round(hit_target / count * 100, 1),
|
|
||||||
"hit_stop_rate": round(hit_stop / count * 100, 1),
|
|
||||||
"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]),
|
|
||||||
})
|
|
||||||
return windows
|
|
||||||
|
|
||||||
|
|
||||||
def _build_failure_cases(rows: list[dict]) -> list[dict]:
|
|
||||||
failures = [
|
|
||||||
r for r in rows
|
|
||||||
if (r.get("pct_from_entry") or 0) < 0
|
|
||||||
or (r.get("close_reason") in {"hit_stop_loss", "review_expired_loss", "review_expired_flat"})
|
|
||||||
or (r.get("max_drawdown_pct") or 0) < -5
|
|
||||||
]
|
|
||||||
failures.sort(key=lambda r: ((r.get("pct_from_entry") or 0), (r.get("max_drawdown_pct") or 0)))
|
|
||||||
return [_case_summary(r) for r in failures[:8]]
|
|
||||||
|
|
||||||
|
|
||||||
def _build_success_patterns(rows: list[dict]) -> list[dict]:
|
|
||||||
successes = [
|
|
||||||
r for r in rows
|
|
||||||
if r.get("close_reason") == "hit_target"
|
|
||||||
or (r.get("max_return_pct") or 0) >= 3
|
|
||||||
or (r.get("pct_from_entry") or 0) > 2
|
|
||||||
]
|
|
||||||
successes.sort(key=lambda r: (r.get("max_return_pct") or 0), reverse=True)
|
|
||||||
return [_case_summary(r) for r in successes[:8]]
|
|
||||||
|
|
||||||
|
|
||||||
def _case_summary(row: dict) -> dict:
|
|
||||||
recall_tags = row.get("recall_tags") or "[]"
|
|
||||||
try:
|
|
||||||
tags = json.loads(recall_tags) if isinstance(recall_tags, str) else recall_tags
|
|
||||||
except Exception:
|
|
||||||
tags = []
|
|
||||||
return {
|
|
||||||
"ts_code": row.get("ts_code"),
|
|
||||||
"name": row.get("name"),
|
|
||||||
"sector": row.get("sector") or "",
|
|
||||||
"strategy": row.get("strategy") or "unknown",
|
|
||||||
"entry_signal_type": row.get("entry_signal_type") or "unknown",
|
|
||||||
"action_plan": row.get("action_plan") or "观察",
|
|
||||||
"score": row.get("score") or 0,
|
|
||||||
"market_temp_score": row.get("market_temp_score") or 0,
|
|
||||||
"sector_score": row.get("sector_score") or 0,
|
|
||||||
"capital_score": row.get("capital_score") or 0,
|
|
||||||
"position_score": row.get("position_score") or 50,
|
|
||||||
"pct_from_entry": row.get("pct_from_entry") or 0,
|
|
||||||
"max_return_pct": row.get("max_return_pct") or 0,
|
|
||||||
"max_drawdown_pct": row.get("max_drawdown_pct") or 0,
|
|
||||||
"days_since_recommendation": row.get("days_since_recommendation") or 0,
|
|
||||||
"close_reason": row.get("close_reason") or "",
|
|
||||||
"review_note": row.get("review_note") or "",
|
|
||||||
"recall_tags": tags,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_agent_patch_prompts(
|
|
||||||
strategy_stats: list[dict],
|
|
||||||
signal_stats: list[dict],
|
|
||||||
failure_patterns: list[str],
|
|
||||||
failure_cases: list[dict],
|
|
||||||
sample_size: int,
|
|
||||||
) -> list[dict]:
|
|
||||||
if sample_size < 10:
|
|
||||||
return []
|
|
||||||
|
|
||||||
prompts = []
|
|
||||||
weak_strategy = next(
|
|
||||||
(
|
|
||||||
s for s in strategy_stats
|
|
||||||
if s["count"] >= 3 and s["win_rate"] < 40 and s["avg_return"] < 0
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if weak_strategy:
|
|
||||||
prompts.append(_patch_prompt(
|
|
||||||
title=f"收紧 {weak_strategy['name']} 策略配置",
|
|
||||||
severity="high",
|
|
||||||
evidence=f"样本{weak_strategy['count']}条,胜率{weak_strategy['win_rate']}%,平均收益{weak_strategy['avg_return']}%。",
|
|
||||||
target_files=["backend/app/llm/strategy_config.py", "backend/app/llm/strategy_selector.py"],
|
|
||||||
prompt=(
|
|
||||||
f"请基于策略复盘收紧 {weak_strategy['name']}。优先通过策略配置版本调整完成,不要改无关代码。"
|
|
||||||
f"证据:{weak_strategy['count']}条样本,胜率{weak_strategy['win_rate']}%,平均收益{weak_strategy['avg_return']}%,"
|
|
||||||
f"平均最大回撤{weak_strategy['avg_max_drawdown']}%。"
|
|
||||||
"请提高 buy_threshold 1-2 分,降低 actionable_limit 或 max_position_pct,并保留回滚记录。"
|
|
||||||
),
|
|
||||||
))
|
|
||||||
|
|
||||||
weak_signal = next(
|
|
||||||
(
|
|
||||||
s for s in signal_stats
|
|
||||||
if s["count"] >= 3 and s["avg_max_drawdown"] < -5
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if weak_signal:
|
|
||||||
prompts.append(_patch_prompt(
|
|
||||||
title=f"降低 {weak_signal['name']} 信号风险暴露",
|
|
||||||
severity="medium",
|
|
||||||
evidence=f"样本{weak_signal['count']}条,平均最大回撤{weak_signal['avg_max_drawdown']}%。",
|
|
||||||
target_files=["backend/app/engine/screener.py", "backend/app/analysis/breakout_signals.py"],
|
|
||||||
prompt=(
|
|
||||||
f"请基于复盘结果降低 {weak_signal['name']} 信号的风险暴露。"
|
|
||||||
f"证据:样本{weak_signal['count']}条,平均最大回撤{weak_signal['avg_max_drawdown']}%,"
|
|
||||||
f"命中止损{weak_signal['hit_stop']}次。"
|
|
||||||
"请检查该信号的入场质量、位置过滤和止损设置,给出最小代码补丁,并保持其他信号行为不变。"
|
|
||||||
),
|
|
||||||
))
|
|
||||||
|
|
||||||
if any("弱势市场" in p for p in failure_patterns):
|
|
||||||
prompts.append(_patch_prompt(
|
|
||||||
title="强化弱势市场防守配置",
|
|
||||||
severity="high",
|
|
||||||
evidence="复盘显示弱势市场亏损样本集中。",
|
|
||||||
target_files=["backend/app/llm/strategy_config.py", "backend/app/llm/strategy_selector.py"],
|
|
||||||
prompt=(
|
|
||||||
"请强化弱势市场防守配置。证据:复盘显示市场温度低于45时亏损样本集中。"
|
|
||||||
"优先把低温环境下的 allow_trading、actionable_limit、buy_threshold 做成可配置护栏,"
|
|
||||||
"小幅收紧可自动生效,大幅禁用策略需生成待确认变更。"
|
|
||||||
),
|
|
||||||
))
|
|
||||||
|
|
||||||
if failure_cases and not prompts:
|
|
||||||
worst = failure_cases[0]
|
|
||||||
prompts.append(_patch_prompt(
|
|
||||||
title="复查推荐失效样本的共同过滤条件",
|
|
||||||
severity="medium",
|
|
||||||
evidence=f"最差样本 {worst['name']} 收益{worst['pct_from_entry']}%,最大回撤{worst['max_drawdown_pct']}%。",
|
|
||||||
target_files=["backend/app/engine/screener.py", "backend/app/llm/strategy_iteration.py"],
|
|
||||||
prompt=(
|
|
||||||
"请复查最近推荐失效样本的共同过滤条件,优先寻找可配置化的收紧项。"
|
|
||||||
f"最差样本:{worst['name']}({worst['ts_code']}),策略{worst['strategy']},"
|
|
||||||
f"信号{worst['entry_signal_type']},收益{worst['pct_from_entry']}%,"
|
|
||||||
f"最大回撤{worst['max_drawdown_pct']}%。"
|
|
||||||
"请不要凭单一样本大改策略,必须保留样本数门槛。"
|
|
||||||
),
|
|
||||||
))
|
|
||||||
|
|
||||||
return prompts[:4]
|
|
||||||
|
|
||||||
|
|
||||||
def _patch_prompt(title: str, severity: str, evidence: str, target_files: list[str], prompt: str) -> dict:
|
|
||||||
return {
|
|
||||||
"title": title,
|
|
||||||
"severity": severity,
|
|
||||||
"evidence": evidence,
|
|
||||||
"target_files": target_files,
|
|
||||||
"prompt": prompt,
|
|
||||||
"acceptance_criteria": [
|
|
||||||
"python3 -m compileall backend/app 通过",
|
|
||||||
"策略配置变更有版本记录且可回滚",
|
|
||||||
"历史推荐和跟踪数据读取不受影响",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _derive_feedback_controls(report: dict) -> dict:
|
|
||||||
suggestions = report.get("adjustment_suggestions", []) or []
|
|
||||||
sample_size = int(report.get("sample_size") or 0)
|
|
||||||
|
|
||||||
controls = {
|
|
||||||
"sample_size": sample_size,
|
|
||||||
"enabled": sample_size >= 10,
|
|
||||||
"buy_threshold_delta": 0,
|
|
||||||
"max_position_pct_delta": 0,
|
|
||||||
"actionable_limit_delta": 0,
|
|
||||||
"watch_limit_delta": 0,
|
|
||||||
"force_defensive": False,
|
|
||||||
"notes": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
if sample_size < 10:
|
|
||||||
controls["notes"].append("样本不足,暂不启用自动回写。")
|
|
||||||
return controls
|
|
||||||
|
|
||||||
promote_count = 0
|
|
||||||
tighten_count = 0
|
|
||||||
reduce_count = 0
|
|
||||||
|
|
||||||
for item in suggestions[:6]:
|
|
||||||
action = item.get("action")
|
|
||||||
reason = item.get("reason", "")
|
|
||||||
|
|
||||||
if action == "promote":
|
|
||||||
promote_count += 1
|
|
||||||
controls["buy_threshold_delta"] -= 1
|
|
||||||
controls["watch_limit_delta"] += 1
|
|
||||||
elif action == "tighten":
|
|
||||||
tighten_count += 1
|
|
||||||
controls["buy_threshold_delta"] += 1
|
|
||||||
controls["actionable_limit_delta"] -= 1
|
|
||||||
controls["max_position_pct_delta"] -= 5
|
|
||||||
elif action == "reduce":
|
|
||||||
reduce_count += 1
|
|
||||||
controls["buy_threshold_delta"] += 1
|
|
||||||
controls["watch_limit_delta"] -= 1
|
|
||||||
|
|
||||||
if "弱势市场" in reason or item.get("target") == "defensive_watch":
|
|
||||||
controls["force_defensive"] = True
|
|
||||||
|
|
||||||
controls["buy_threshold_delta"] = max(-2, min(3, controls["buy_threshold_delta"]))
|
|
||||||
controls["max_position_pct_delta"] = max(-10, min(5, controls["max_position_pct_delta"]))
|
|
||||||
controls["actionable_limit_delta"] = max(-2, min(1, controls["actionable_limit_delta"]))
|
|
||||||
controls["watch_limit_delta"] = max(-2, min(2, controls["watch_limit_delta"]))
|
|
||||||
|
|
||||||
if controls["force_defensive"]:
|
|
||||||
controls["notes"].append("最近弱市亏损样本偏多,优先启用防守约束。")
|
|
||||||
elif tighten_count > promote_count:
|
|
||||||
controls["notes"].append("最近失效样本偏多,整体建议略收紧。")
|
|
||||||
elif promote_count > 0 and reduce_count == 0:
|
|
||||||
controls["notes"].append("最近有效样本改善,可适度放宽观察与出手空间。")
|
|
||||||
else:
|
|
||||||
controls["notes"].append("最近样本无明显单边倾向,仅做轻微校正。")
|
|
||||||
|
|
||||||
return controls
|
|
||||||
|
|
||||||
|
|
||||||
async def _generate_ai_iteration(rule_report: dict, rows: list[dict]) -> str:
|
|
||||||
from app.llm.client import chat_completion
|
|
||||||
from app.llm.prompts import STRATEGY_ITERATION_PROMPT
|
|
||||||
from app.llm.strategy_config import get_prompt_content
|
|
||||||
|
|
||||||
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]
|
|
||||||
]
|
|
||||||
|
|
||||||
prompt = await get_prompt_content("strategy_iteration", STRATEGY_ITERATION_PROMPT)
|
|
||||||
user_msg = f"""{prompt}
|
|
||||||
|
|
||||||
规则复盘:
|
|
||||||
{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)
|
|
||||||
@ -1,11 +1,9 @@
|
|||||||
"""动态策略选择器
|
"""动态策略选择器
|
||||||
|
|
||||||
在固定筛选引擎前增加一层“先选打法,再选股票”的策略决策。
|
根据市场温度和板块状态,纯规则选择当日策略 profile。
|
||||||
生产筛选只使用规则和策略配置,保证同一份行情输入得到稳定输出。
|
不再使用 LLM 或数据库配置覆盖。
|
||||||
LLM 只能用于离线复盘、配置建议或解释,不参与盘中策略换挡。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@ -46,6 +44,23 @@ def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile:
|
|||||||
watch_cap = max(0, settings.watch_limit)
|
watch_cap = max(0, settings.watch_limit)
|
||||||
|
|
||||||
profiles = {
|
profiles = {
|
||||||
|
"pre_market_ambush": StrategyProfile(
|
||||||
|
strategy_id="pre_market_ambush",
|
||||||
|
name="盘前埋伏",
|
||||||
|
description="盘前选出缩量整理到位、回踩支撑、尚未启动的埋伏标的。",
|
||||||
|
entry_signal_priority=["launch", "pullback", "breakout_confirm", "reversal", "breakout"],
|
||||||
|
score_weights={"catalyst": 0.25, "theme_money": 0.22, "stock_money": 0.18, "emotion_role": 0.10, "timing": 0.25},
|
||||||
|
min_score=55,
|
||||||
|
buy_threshold=58,
|
||||||
|
max_position_pct=25,
|
||||||
|
allow_trading=True,
|
||||||
|
actionable_limit=min(3, actionable_cap),
|
||||||
|
watch_limit=min(5, watch_cap),
|
||||||
|
target_focus_sectors=3,
|
||||||
|
market_stance="埋伏待发",
|
||||||
|
decision_note="盘前选票重点看整理充分度和催化预期,不追已启动标的。",
|
||||||
|
notes=["优先缩量整理到位+催化预期", "回踩支撑位附近的主线成分优先"],
|
||||||
|
),
|
||||||
"breakout_attack": StrategyProfile(
|
"breakout_attack": StrategyProfile(
|
||||||
strategy_id="breakout_attack",
|
strategy_id="breakout_attack",
|
||||||
name="主线突破",
|
name="主线突破",
|
||||||
@ -122,11 +137,12 @@ async def select_strategy_profile(
|
|||||||
market_temp: MarketTemperature | None,
|
market_temp: MarketTemperature | None,
|
||||||
hot_sectors: list[SectorInfo],
|
hot_sectors: list[SectorInfo],
|
||||||
intraday: bool,
|
intraday: bool,
|
||||||
|
scan_session: str = "",
|
||||||
) -> StrategyProfile:
|
) -> StrategyProfile:
|
||||||
from app.llm.strategy_config import load_active_strategy_profile
|
"""纯规则策略选择。盘前埋伏 session 走独立 profile。"""
|
||||||
|
if scan_session == "pre_market_ambush" or scan_session == "pre_market":
|
||||||
profile = _select_rule_profile(market_temp, hot_sectors, intraday)
|
return get_strategy_profile_by_id("pre_market_ambush")
|
||||||
return await load_active_strategy_profile(profile)
|
return _select_rule_profile(market_temp, hot_sectors, intraday)
|
||||||
|
|
||||||
|
|
||||||
def _select_rule_profile(
|
def _select_rule_profile(
|
||||||
@ -148,106 +164,3 @@ def _select_rule_profile(
|
|||||||
return get_strategy_profile_by_id("launch_probe")
|
return get_strategy_profile_by_id("launch_probe")
|
||||||
|
|
||||||
return get_strategy_profile_by_id("defensive_watch")
|
return get_strategy_profile_by_id("defensive_watch")
|
||||||
|
|
||||||
|
|
||||||
async def _apply_strategy_feedback(profile: StrategyProfile) -> StrategyProfile:
|
|
||||||
from app.llm.strategy_iteration import build_strategy_feedback_controls
|
|
||||||
|
|
||||||
try:
|
|
||||||
controls = await build_strategy_feedback_controls(limit=50)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"策略反馈控制生成失败: {e}")
|
|
||||||
return profile
|
|
||||||
|
|
||||||
if not controls.get("enabled"):
|
|
||||||
return profile
|
|
||||||
|
|
||||||
updated = profile.model_copy(deep=True)
|
|
||||||
updated.feedback_applied = True
|
|
||||||
|
|
||||||
if controls.get("force_defensive"):
|
|
||||||
updated.allow_trading = False
|
|
||||||
updated.actionable_limit = 0
|
|
||||||
updated.watch_limit = min(updated.watch_limit, 3)
|
|
||||||
updated.max_position_pct = min(updated.max_position_pct, 10)
|
|
||||||
updated.market_stance = "防守观察"
|
|
||||||
|
|
||||||
updated.buy_threshold = max(updated.min_score, min(updated.buy_threshold + int(controls.get("buy_threshold_delta") or 0), 80))
|
|
||||||
updated.max_position_pct = max(0, min(updated.max_position_pct + int(controls.get("max_position_pct_delta") or 0), 40))
|
|
||||||
updated.actionable_limit = max(0, min(updated.actionable_limit + int(controls.get("actionable_limit_delta") or 0), settings.actionable_limit))
|
|
||||||
updated.watch_limit = max(1, min(updated.watch_limit + int(controls.get("watch_limit_delta") or 0), settings.watch_limit))
|
|
||||||
|
|
||||||
notes = controls.get("notes") or []
|
|
||||||
if notes:
|
|
||||||
updated.feedback_notes = notes[:3]
|
|
||||||
updated.notes.extend(notes[:2])
|
|
||||||
updated.decision_note = notes[0]
|
|
||||||
|
|
||||||
updated.generated_by = f"{updated.generated_by}+feedback"
|
|
||||||
return updated
|
|
||||||
|
|
||||||
|
|
||||||
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 = get_strategy_profile_by_id(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
|
|
||||||
|
|||||||
@ -74,23 +74,28 @@ def _clean_for_json(obj):
|
|||||||
|
|
||||||
|
|
||||||
async def _get_strategy_board() -> str:
|
async def _get_strategy_board() -> str:
|
||||||
from app.llm.strategy_board import build_strategy_board
|
"""返回当日市场概况 + 策略状态,替代已删除的 strategy_board 模块。"""
|
||||||
|
from app.engine.recommender import get_latest_recommendations
|
||||||
|
|
||||||
|
result = await get_latest_recommendations()
|
||||||
|
mt = result.get("market_temp")
|
||||||
|
recs = result.get("recommendations", [])
|
||||||
|
strategy = result.get("strategy_profile") or {}
|
||||||
|
|
||||||
|
actionable = [r for r in recs if r.action_plan == "可操作"]
|
||||||
|
watch = [r for r in recs if r.action_plan == "重点关注"]
|
||||||
|
|
||||||
board = await build_strategy_board(include_llm=False)
|
|
||||||
payload = {
|
payload = {
|
||||||
"trade_date": board.get("trade_date", ""),
|
"market_temperature": mt.temperature if mt else 0,
|
||||||
"market_regime": board.get("market_regime", ""),
|
"up_count": mt.up_count if mt else 0,
|
||||||
"risk_level": board.get("risk_level", ""),
|
"down_count": mt.down_count if mt else 0,
|
||||||
"action_bias": board.get("action_bias", ""),
|
"limit_up_count": mt.limit_up_count if mt else 0,
|
||||||
"position_suggestion": board.get("position_suggestion", ""),
|
"strategy_name": strategy.get("name", "未知"),
|
||||||
"summary": board.get("summary", ""),
|
"market_stance": strategy.get("market_stance", ""),
|
||||||
"recommended_mode": board.get("recommended_mode", ""),
|
"decision_note": strategy.get("decision_note", ""),
|
||||||
"watch_sectors": board.get("watch_sectors", [])[:5],
|
"actionable_count": len(actionable),
|
||||||
"strategy_focus": board.get("strategy_focus", [])[:4],
|
"watch_count": len(watch),
|
||||||
"avoid_rules": board.get("avoid_rules", [])[:4],
|
"total_recommendations": len(recs),
|
||||||
"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)
|
return json.dumps(_clean_for_json(payload), ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from app.config import settings
|
|||||||
from app.db.error_logger import PersistentErrorLogHandler, log_error
|
from app.db.error_logger import PersistentErrorLogHandler, log_error
|
||||||
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, watchlists, websocket, chat, auth, debug, catalysts
|
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, catalysts
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -103,9 +103,6 @@ async def lifespan(app: FastAPI):
|
|||||||
await init_db()
|
await init_db()
|
||||||
logger.info("数据库初始化完成")
|
logger.info("数据库初始化完成")
|
||||||
await ensure_admin_exists()
|
await ensure_admin_exists()
|
||||||
from app.llm.strategy_config import ensure_default_configs
|
|
||||||
await ensure_default_configs()
|
|
||||||
logger.info("策略配置中心初始化完成")
|
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
logger.info("调度器已启动")
|
logger.info("调度器已启动")
|
||||||
yield
|
yield
|
||||||
@ -147,7 +144,6 @@ app.include_router(stocks.router)
|
|||||||
app.include_router(watchlists.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(catalysts.router)
|
app.include_router(catalysts.router)
|
||||||
|
|
||||||
# WebSocket
|
# WebSocket
|
||||||
|
|||||||
@ -1,183 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
|
||||||
import { getDataSourceHealthAPI, type DataSourceHealthResult } from "@/lib/api";
|
|
||||||
|
|
||||||
export default function DataHealthPage() {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [days, setDays] = useState(7);
|
|
||||||
const [data, setData] = useState<DataSourceHealthResult | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
setData(await getDataSourceHealthAPI(days));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [days]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user?.role === "admin") loadData();
|
|
||||||
}, [user, loadData]);
|
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
|
||||||
const sources = data?.sources || [];
|
|
||||||
return {
|
|
||||||
total: sources.length,
|
|
||||||
errors: sources.filter((item) => item.status === "error").length,
|
|
||||||
warnings: sources.filter((item) => item.status === "warning").length,
|
|
||||||
ok: sources.filter((item) => item.status === "ok").length,
|
|
||||||
};
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (user?.role !== "admin") {
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6">
|
|
||||||
<p className="text-sm text-text-muted">需要管理员权限</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
|
||||||
<header className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-text-muted">Data Health</div>
|
|
||||||
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">数据源健康</h1>
|
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-text-secondary">
|
|
||||||
东方财富、腾讯、Tushare、AKShare 等数据源的错误聚合和本地数据新鲜度。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={days}
|
|
||||||
onChange={(event) => setDays(Number(event.target.value))}
|
|
||||||
className="rounded-xl border border-border-default bg-surface-2 px-3 py-2 text-xs text-text-primary outline-none focus:ring-1 focus:ring-amber-500/30"
|
|
||||||
>
|
|
||||||
<option value={1}>1天</option>
|
|
||||||
<option value={3}>3天</option>
|
|
||||||
<option value={7}>7天</option>
|
|
||||||
<option value={14}>14天</option>
|
|
||||||
<option value={30}>30天</option>
|
|
||||||
</select>
|
|
||||||
<button onClick={loadData} className="rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-xs font-medium text-text-secondary transition-all hover:bg-surface-4 hover:text-text-primary">
|
|
||||||
刷新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
||||||
<MetricCard label="数据源" value={summary.total} />
|
|
||||||
<MetricCard label="正常" value={summary.ok} tone="ok" />
|
|
||||||
<MetricCard label="警告" value={summary.warnings} tone="warning" />
|
|
||||||
<MetricCard label="错误" value={summary.errors} tone="error" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_360px]">
|
|
||||||
<div className="glass-card-static overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
|
||||||
<h2 className="text-sm font-semibold text-text-primary">数据源状态</h2>
|
|
||||||
<span className="text-[10px] text-text-muted">{data ? formatDateTime(data.generated_at) : "-"}</span>
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-3 p-4">
|
|
||||||
{[1, 2, 3, 4].map((item) => <div key={item} className="h-20 animate-shimmer rounded-xl bg-surface-2" />)}
|
|
||||||
</div>
|
|
||||||
) : !data?.sources.length ? (
|
|
||||||
<div className="p-10 text-center text-sm text-text-muted">暂无数据源记录</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-border-subtle">
|
|
||||||
{data.sources.map((item) => (
|
|
||||||
<article key={item.source} className="grid gap-3 px-4 py-4 lg:grid-cols-[150px_110px_120px_1fr_140px] lg:items-center">
|
|
||||||
<div className="text-sm font-semibold text-text-primary">{sourceLabel(item.source)}</div>
|
|
||||||
<StatusPill status={item.status} />
|
|
||||||
<div className="font-mono text-xs tabular-nums text-text-secondary">
|
|
||||||
{item.error_count}错 / {item.warning_count}警
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 text-xs leading-5 text-text-muted line-clamp-2">
|
|
||||||
{item.last_error || "最近没有记录到异常"}
|
|
||||||
</div>
|
|
||||||
<div className="font-mono text-[10px] tabular-nums text-text-muted lg:text-right">
|
|
||||||
{formatDateTime(item.last_seen_at)}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="glass-card-static p-4 self-start">
|
|
||||||
<h2 className="text-sm font-semibold text-text-primary">本地数据新鲜度</h2>
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
{Object.entries(data?.freshness || {}).map(([key, value]) => (
|
|
||||||
<div key={key} className="rounded-xl border border-border-subtle bg-surface-1/70 p-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-xs font-semibold text-text-primary">{freshnessLabel(key)}</span>
|
|
||||||
<span className="font-mono text-[10px] text-text-muted">{formatDateTime(value.created_at || "")}</span>
|
|
||||||
</div>
|
|
||||||
{"trade_date" in value ? (
|
|
||||||
<div className="mt-2 text-xs text-text-secondary">交易日 {value.trade_date || "-"}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!data && <div className="rounded-xl border border-border-subtle bg-surface-1/70 p-5 text-sm text-text-muted">读取中...</div>}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricCard({ label, value, tone = "default" }: { label: string; value: number; tone?: "default" | "ok" | "warning" | "error" }) {
|
|
||||||
const color = tone === "ok" ? "text-emerald-400" : tone === "warning" ? "text-amber-400" : tone === "error" ? "text-red-400" : "text-text-primary";
|
|
||||||
return (
|
|
||||||
<div className="glass-card-static p-4">
|
|
||||||
<div className="text-[10px] text-text-muted">{label}</div>
|
|
||||||
<div className={`mt-2 font-mono text-2xl font-bold tabular-nums ${color}`}>{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusPill({ status }: { status: string }) {
|
|
||||||
const className = status === "ok"
|
|
||||||
? "border-emerald-500/15 bg-emerald-500/[0.06] text-emerald-400"
|
|
||||||
: status === "warning"
|
|
||||||
? "border-amber-500/15 bg-amber-500/[0.07] text-amber-400"
|
|
||||||
: "border-red-500/15 bg-red-500/[0.07] text-red-400";
|
|
||||||
const label = status === "ok" ? "正常" : status === "warning" ? "警告" : "异常";
|
|
||||||
return <span className={`w-fit rounded-md border px-2 py-0.5 text-[10px] font-medium ${className}`}>{label}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sourceLabel(source: string) {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
eastmoney: "东方财富",
|
|
||||||
tencent: "腾讯行情",
|
|
||||||
tushare: "Tushare",
|
|
||||||
akshare: "AKShare",
|
|
||||||
sina: "新浪行情",
|
|
||||||
news: "新闻管道",
|
|
||||||
tushare_news: "Tushare新闻",
|
|
||||||
};
|
|
||||||
return map[source] || source;
|
|
||||||
}
|
|
||||||
|
|
||||||
function freshnessLabel(key: string) {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
market_temperature: "市场温度",
|
|
||||||
sector_heat: "板块热度",
|
|
||||||
recommendations: "推荐结论",
|
|
||||||
news_items: "新闻原文",
|
|
||||||
catalysts: "舆情催化",
|
|
||||||
};
|
|
||||||
return map[key] || key;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(value: string) {
|
|
||||||
if (!value) return "-";
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
|
|
||||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
|
||||||
}
|
|
||||||
@ -1,551 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { fetchAPI, type DiagnosisResult, type StockThesisResponse } from "@/lib/api";
|
|
||||||
import { markdownToHtml } from "@/lib/markdown";
|
|
||||||
import { ErrorBoundary } from "@/components/error-boundary";
|
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
ts_code: string;
|
|
||||||
name: string;
|
|
||||||
industry: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DiagnoseHistoryItem {
|
|
||||||
ts_code: string;
|
|
||||||
name: string;
|
|
||||||
diagnosis_mode?: string;
|
|
||||||
diagnosis?: string;
|
|
||||||
created_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DiagnosePage() {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const codeParam = searchParams.get("code");
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [streamingContent, setStreamingContent] = useState("");
|
|
||||||
const [result, setResult] = useState<DiagnosisResult | null>(null);
|
|
||||||
const [cachedResult, setCachedResult] = useState<string | null>(null);
|
|
||||||
const [history, setHistory] = useState<{ ts_code: string; name: string }[]>([]);
|
|
||||||
const [diagnoseHistory, setDiagnoseHistory] = useState<DiagnoseHistoryItem[]>([]);
|
|
||||||
const [thesis, setThesis] = useState<StockThesisResponse | null>(null);
|
|
||||||
const [diagnoseMode, setDiagnoseMode] = useState<"entry" | "holding" | "review" | "tracking">("entry");
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
|
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClick(e: MouseEvent) {
|
|
||||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
|
||||||
setShowSearch(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClick);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!codeParam) return;
|
|
||||||
setInput(codeParam);
|
|
||||||
runDiagnosis(codeParam);
|
|
||||||
}, [codeParam]);
|
|
||||||
|
|
||||||
const searchStock = useCallback(async (keyword: string) => {
|
|
||||||
if (!keyword.trim() || keyword.length < 1) {
|
|
||||||
setSearchResults([]);
|
|
||||||
setShowSearch(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await fetchAPI<SearchResult[]>(`/api/stocks/search?keyword=${encodeURIComponent(keyword)}`);
|
|
||||||
setSearchResults(data);
|
|
||||||
setShowSearch(data.length > 0);
|
|
||||||
} catch {
|
|
||||||
setSearchResults([]);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleInputChange = (value: string) => {
|
|
||||||
setInput(value);
|
|
||||||
clearTimeout(searchTimer.current);
|
|
||||||
searchTimer.current = setTimeout(() => searchStock(value), 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectStock = (stock: SearchResult) => {
|
|
||||||
setInput(`${stock.name} (${stock.ts_code})`);
|
|
||||||
setShowSearch(false);
|
|
||||||
setSearchResults([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const runDiagnosis = async (tsCode?: string) => {
|
|
||||||
let code = tsCode;
|
|
||||||
if (!code) {
|
|
||||||
const match = input.match(/\((\d{6}\.[A-Z]{2})\)/);
|
|
||||||
if (match) {
|
|
||||||
code = match[1];
|
|
||||||
} else if (/^\d{6}$/.test(input.trim())) {
|
|
||||||
code = `${input.trim()}.SH`;
|
|
||||||
} else if (/^\d{6}\.[A-Z]{2}$/.test(input.trim())) {
|
|
||||||
code = input.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!code) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setStreamingContent("");
|
|
||||||
setResult(null);
|
|
||||||
setCachedResult(null);
|
|
||||||
|
|
||||||
fetchAPI<StockThesisResponse>(`/api/stocks/${code}/thesis`).then(setThesis).catch(() => setThesis(null));
|
|
||||||
fetchAPI<DiagnoseHistoryItem[]>(`/api/stocks/${code}/diagnose/history`).then(setDiagnoseHistory).catch(() => setDiagnoseHistory([]));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem("auth_token");
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
|
|
||||||
const res = await fetch(`/api/stocks/${code}/diagnose?mode=${encodeURIComponent(diagnoseMode)}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
|
||||||
if (!res.body) throw new Error("No response body");
|
|
||||||
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
let fullContent = "";
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split("\n");
|
|
||||||
buffer = lines.pop() || "";
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith("data: ")) {
|
|
||||||
const data = line.slice(6).trim();
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data);
|
|
||||||
|
|
||||||
if (parsed.cached && parsed.diagnosis) {
|
|
||||||
setCachedResult(parsed.diagnosis);
|
|
||||||
fullContent = parsed.diagnosis;
|
|
||||||
} else if (parsed.token) {
|
|
||||||
fullContent += parsed.token;
|
|
||||||
setStreamingContent(fullContent);
|
|
||||||
} else if (parsed.error) {
|
|
||||||
setResult({ status: "error", message: parsed.error });
|
|
||||||
} else if (parsed.done) {
|
|
||||||
if (fullContent) {
|
|
||||||
setResult({ status: "ok", ts_code: parsed.ts_code || code, diagnosis: fullContent });
|
|
||||||
const name = input.split(" (")[0] || parsed.ts_code || code;
|
|
||||||
setHistory((prev) => {
|
|
||||||
const filtered = prev.filter((h) => h.ts_code !== (parsed.ts_code || code));
|
|
||||||
return [{ ts_code: parsed.ts_code || code, name }, ...filtered].slice(0, 10);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore malformed lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setResult({ status: "error", message: "诊断失败,请检查股票代码后重试" });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" && !showSearch) {
|
|
||||||
e.preventDefault();
|
|
||||||
runDiagnosis();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayContent = cachedResult || result?.diagnosis || streamingContent;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-6 animate-fade-in-up">
|
|
||||||
<div className="flex items-center gap-3 mb-1">
|
|
||||||
<div className="w-8 h-8 rounded-xl bg-gradient-to-br from-amber-500/25 to-amber-600/15 flex items-center justify-center border border-amber-500/15">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-400">
|
|
||||||
<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>
|
|
||||||
<h1 className="text-lg font-bold tracking-tight">个股诊断</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_320px] gap-5">
|
|
||||||
<section className="space-y-5">
|
|
||||||
<div className="glass-card-static p-4 mb-1 animate-fade-in-up">
|
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold mb-2">诊断工作台</div>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_auto] gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
|
||||||
{[
|
|
||||||
{ key: "entry", label: "建仓前诊断" },
|
|
||||||
{ key: "holding", label: "持仓复核" },
|
|
||||||
{ key: "review", label: "回撤复盘" },
|
|
||||||
{ key: "tracking", label: "继续跟踪" },
|
|
||||||
].map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
onClick={() => setDiagnoseMode(item.key as typeof diagnoseMode)}
|
|
||||||
className={`text-xs px-3 py-1.5 rounded-lg transition-all ${
|
|
||||||
diagnoseMode === item.key
|
|
||||||
? "bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/15"
|
|
||||||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div ref={wrapperRef} className="relative z-30">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => handleInputChange(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onFocus={() => searchResults.length > 0 && setShowSearch(true)}
|
|
||||||
placeholder="输入股票名称或代码,如 600683 或 京投发展"
|
|
||||||
className="w-full bg-surface-2 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-1 focus:ring-amber-400/30 placeholder-text-muted/40 border border-border-subtle transition-all duration-200"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => runDiagnosis()}
|
|
||||||
disabled={loading || !input.trim()}
|
|
||||||
className="px-6 py-3 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl text-sm font-medium hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-30 transition-all duration-200 border border-amber-500/10 shrink-0"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<span className="w-3.5 h-3.5 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
|
||||||
分析中
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"开始诊断"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showSearch && searchResults.length > 0 && (
|
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 bg-bg-secondary border border-border-subtle rounded-xl shadow-lg z-20 overflow-hidden">
|
|
||||||
{searchResults.map((stock) => (
|
|
||||||
<button
|
|
||||||
key={stock.ts_code}
|
|
||||||
onClick={() => selectStock(stock)}
|
|
||||||
className="w-full flex items-center justify-between px-4 py-2.5 text-sm hover:bg-surface-3 transition-colors text-left"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-text-primary font-medium">{stock.name}</span>
|
|
||||||
<span className="text-text-muted text-xs">{stock.ts_code}</span>
|
|
||||||
</div>
|
|
||||||
{stock.industry && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-2 text-text-muted">
|
|
||||||
{stock.industry}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{thesis ? (
|
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">诊断依据</div>
|
|
||||||
<div className="text-sm text-text-secondary mt-2 leading-relaxed">
|
|
||||||
已读取该股最近推荐、跟踪和诊断记录。
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-2 mt-3">
|
|
||||||
{(thesis.decision_points ?? []).slice(0, 3).map((point) => (
|
|
||||||
<DiagnosisSummaryCard key={point.label} label={point.label} value={point.value} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 text-sm text-text-muted">
|
|
||||||
输入股票后,这里会展示推荐归档、跟踪状态和最近诊断上下文。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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">正在分析...</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-text-secondary leading-relaxed whitespace-pre-line">
|
|
||||||
{displayContent}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Final Result */}
|
|
||||||
{!loading && displayContent && (
|
|
||||||
<div className="animate-fade-in-up space-y-4">
|
|
||||||
<div className="glass-card-static p-4">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold mb-1">结构化结论</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => runDiagnosis(result?.ts_code || codeParam || undefined)}
|
|
||||||
className="text-xs text-text-muted hover:text-amber-400 transition-colors flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M1 4v6h6" />
|
|
||||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
|
||||||
</svg>
|
|
||||||
重新诊断
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-2 text-sm">
|
|
||||||
<DiagnosisSummaryCard label="诊断模式" value={getModeLabel(diagnoseMode)} />
|
|
||||||
<DiagnosisSummaryCard label="当前结论" value={buildDiagnosisConclusion(thesis, loading)} />
|
|
||||||
<DiagnosisSummaryCard label="所属板块" value={thesis?.recommendation?.sector || "暂无归档"} />
|
|
||||||
<DiagnosisSummaryCard label="风险线索" value={buildDiagnosisRisk(thesis, loading)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="glass-card-static p-4">
|
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold mb-3">会诊摘要</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
||||||
<DiagnosisSummaryCard label="执行建议" value={buildDiagnosisAction(thesis, diagnoseMode)} />
|
|
||||||
<DiagnosisSummaryCard label="下一步" value={buildDiagnosisNextStep(thesis, diagnoseMode)} />
|
|
||||||
<DiagnosisSummaryCard label="触发关注" value={thesis?.recommendation?.trigger_condition || "等待正文补充"} />
|
|
||||||
<DiagnosisSummaryCard label="失效边界" value={thesis?.recommendation?.invalidation_condition || "等待正文补充"} />
|
|
||||||
</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">输入股票代码开始诊断</div>
|
|
||||||
<div className="text-xs text-text-muted/50 mb-4">
|
|
||||||
支持股票代码(如 600683)或名称(如 京投发展)
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
|
||||||
{["贵州茅台", "宁德时代", "比亚迪"].map((name) => (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
onClick={() => {
|
|
||||||
setInput(name);
|
|
||||||
searchStock(name);
|
|
||||||
}}
|
|
||||||
className="text-xs px-3 py-1.5 bg-surface-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-all border border-border-subtle"
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<aside className="space-y-5">
|
|
||||||
{thesis?.latest_tracking ? (
|
|
||||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">最新跟踪</div>
|
|
||||||
<div className="text-sm text-text-secondary mt-2 leading-relaxed">
|
|
||||||
{thesis.latest_tracking.review_note || "暂无备注"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{diagnoseHistory.length > 0 ? (
|
|
||||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-text-muted font-semibold">历史会诊</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
|
||||||
{diagnoseHistory.slice(0, 6).map((item, index) => (
|
|
||||||
<span key={`${item.ts_code}-${index}`} className="text-[11px] px-2 py-1 rounded-lg bg-surface-2 border border-border-subtle text-text-secondary">
|
|
||||||
{getModeLabel((item.diagnosis_mode as "entry" | "holding" | "review" | "tracking") || "entry")}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{history.length > 0 && !displayContent ? (
|
|
||||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-text-muted font-semibold">最近诊断</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
|
||||||
{history.map((h) => (
|
|
||||||
<button
|
|
||||||
key={h.ts_code}
|
|
||||||
onClick={() => {
|
|
||||||
setInput(`${h.name} (${h.ts_code})`);
|
|
||||||
runDiagnosis(h.ts_code);
|
|
||||||
}}
|
|
||||||
className="text-xs px-3 py-1.5 bg-surface-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-all border border-border-subtle"
|
|
||||||
>
|
|
||||||
{h.name}
|
|
||||||
<span className="text-text-muted/50 ml-1">{h.ts_code}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DiagnosisSummaryCard({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl bg-surface-1 border border-border-subtle px-3 py-2">
|
|
||||||
<div className="text-[10px] text-text-muted/60">{label}</div>
|
|
||||||
<div className="text-sm text-text-secondary mt-1 leading-relaxed">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModeLabel(mode: "entry" | "holding" | "review" | "tracking") {
|
|
||||||
const map = {
|
|
||||||
entry: "建仓前诊断",
|
|
||||||
holding: "持仓复核",
|
|
||||||
review: "回撤复盘",
|
|
||||||
tracking: "继续跟踪",
|
|
||||||
};
|
|
||||||
return map[mode];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModeDescription(mode: "entry" | "holding" | "review" | "tracking") {
|
|
||||||
const map = {
|
|
||||||
entry: "重点看能否进场、触发条件和失效条件。",
|
|
||||||
holding: "重点看持仓逻辑是否还成立、需不需要减仓或退出。",
|
|
||||||
review: "重点看为何回撤、问题出在个股还是环境。",
|
|
||||||
tracking: "重点看是否继续保留在观察池和推荐池。",
|
|
||||||
};
|
|
||||||
return map[mode];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDiagnosisConclusion(thesis: StockThesisResponse | null, loading: boolean) {
|
|
||||||
if (thesis?.recommendation?.action_plan) {
|
|
||||||
return thesis.recommendation.action_plan;
|
|
||||||
}
|
|
||||||
if (loading) {
|
|
||||||
return "正在生成会诊";
|
|
||||||
}
|
|
||||||
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 "正在补充风险判断";
|
|
||||||
}
|
|
||||||
return "当前没有明确风险备注";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDiagnosisAction(
|
|
||||||
thesis: StockThesisResponse | null,
|
|
||||||
mode: "entry" | "holding" | "review" | "tracking",
|
|
||||||
) {
|
|
||||||
if (thesis?.recommendation?.action_plan === "可操作") {
|
|
||||||
return "仅在触发条件成立时执行,不提前交易。";
|
|
||||||
}
|
|
||||||
if (thesis?.recommendation?.action_plan === "重点关注") {
|
|
||||||
return "继续跟踪板块和个股强度,等确认后再做动作。";
|
|
||||||
}
|
|
||||||
if (mode === "holding") {
|
|
||||||
return "优先核对持仓逻辑、失效条件和风险暴露。";
|
|
||||||
}
|
|
||||||
if (mode === "review") {
|
|
||||||
return "先定位问题出在市场环境、板块节奏还是个股执行。";
|
|
||||||
}
|
|
||||||
return "当前以观察和补充证据为主。";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDiagnosisNextStep(
|
|
||||||
thesis: StockThesisResponse | null,
|
|
||||||
mode: "entry" | "holding" | "review" | "tracking",
|
|
||||||
) {
|
|
||||||
if (thesis?.recommendation?.trigger_condition) {
|
|
||||||
return `重点盯住:${thesis.recommendation.trigger_condition}`;
|
|
||||||
}
|
|
||||||
if (mode === "tracking") {
|
|
||||||
return "继续观察是否进入可操作或重点关注状态。";
|
|
||||||
}
|
|
||||||
if (mode === "holding") {
|
|
||||||
return "检查是否需要减仓、止损或继续持有。";
|
|
||||||
}
|
|
||||||
return "结合下一个交易时段的量价和板块变化再判断。";
|
|
||||||
}
|
|
||||||
@ -1,686 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
|
||||||
import {
|
|
||||||
clearErrorLogsAPI,
|
|
||||||
getErrorLogsAPI,
|
|
||||||
getResearchObservationsAPI,
|
|
||||||
getScanLogsAPI,
|
|
||||||
getScanSessionsAPI,
|
|
||||||
getSystemStatusAPI,
|
|
||||||
type ErrorLog,
|
|
||||||
type ResearchObservation,
|
|
||||||
type ScanProcessLog,
|
|
||||||
type ScanSessionSummary,
|
|
||||||
type SystemStatus,
|
|
||||||
} from "@/lib/api";
|
|
||||||
|
|
||||||
type OpsTab = "funnel" | "errors";
|
|
||||||
|
|
||||||
const STAGE_ORDER = [
|
|
||||||
"market_temperature",
|
|
||||||
"theme_selection",
|
|
||||||
"strategy_profile",
|
|
||||||
"candidate_recall",
|
|
||||||
"realtime_quote",
|
|
||||||
"rule_scoring",
|
|
||||||
"final_filter",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function OpsLogsPage() {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [tab, setTab] = useState<OpsTab>("errors");
|
|
||||||
const [days, setDays] = useState(7);
|
|
||||||
const [sessions, setSessions] = useState<ScanSessionSummary[]>([]);
|
|
||||||
const [selectedSession, setSelectedSession] = useState("");
|
|
||||||
const [scanLogs, setScanLogs] = useState<ScanProcessLog[]>([]);
|
|
||||||
const [observations, setObservations] = useState<ResearchObservation[]>([]);
|
|
||||||
const [reasonCounts, setReasonCounts] = useState<Record<string, number>>({});
|
|
||||||
const [scanLoading, setScanLoading] = useState(true);
|
|
||||||
|
|
||||||
const [errors, setErrors] = useState<ErrorLog[]>([]);
|
|
||||||
const [errorsTotal, setErrorsTotal] = useState(0);
|
|
||||||
const [sources, setSources] = useState<string[]>([]);
|
|
||||||
const [levels, setLevels] = useState<string[]>([]);
|
|
||||||
const [sourceCounts, setSourceCounts] = useState<Record<string, number>>({});
|
|
||||||
const [levelCounts, setLevelCounts] = useState<Record<string, number>>({});
|
|
||||||
const [source, setSource] = useState("");
|
|
||||||
const [level, setLevel] = useState("");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [errorsLoading, setErrorsLoading] = useState(false);
|
|
||||||
const [expandedErrorId, setExpandedErrorId] = useState<number | null>(null);
|
|
||||||
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
|
|
||||||
|
|
||||||
const latestSession = sessions[0];
|
|
||||||
const activeSession = sessions.find((item) => item.scan_session === selectedSession) || latestSession;
|
|
||||||
const sortedLogs = useMemo(
|
|
||||||
() => [...scanLogs].sort((a, b) => STAGE_ORDER.indexOf(a.stage) - STAGE_ORDER.indexOf(b.stage)),
|
|
||||||
[scanLogs],
|
|
||||||
);
|
|
||||||
const maxCount = Math.max(...sortedLogs.map((item) => Math.max(item.input_count, item.output_count)), 1);
|
|
||||||
const finalLog = sortedLogs.find((item) => item.stage === "final_filter");
|
|
||||||
const latestError = errors[0];
|
|
||||||
|
|
||||||
const fetchScanData = useCallback(async (session?: string) => {
|
|
||||||
setScanLoading(true);
|
|
||||||
try {
|
|
||||||
const sessionData = await getScanSessionsAPI(days, 30);
|
|
||||||
setSessions(sessionData.sessions);
|
|
||||||
const nextSession = session || sessionData.sessions[0]?.scan_session || "";
|
|
||||||
setSelectedSession(nextSession);
|
|
||||||
const logData = await getScanLogsAPI(nextSession || undefined, days, 140);
|
|
||||||
setScanLogs(logData.logs);
|
|
||||||
const observationData = await getResearchObservationsAPI(nextSession || undefined, days, 80);
|
|
||||||
setObservations(observationData.observations);
|
|
||||||
setReasonCounts(observationData.reason_counts);
|
|
||||||
} catch {
|
|
||||||
setScanLogs([]);
|
|
||||||
setObservations([]);
|
|
||||||
setReasonCounts({});
|
|
||||||
} finally {
|
|
||||||
setScanLoading(false);
|
|
||||||
}
|
|
||||||
}, [days]);
|
|
||||||
|
|
||||||
const fetchErrors = useCallback(async () => {
|
|
||||||
setErrorsLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await getErrorLogsAPI(120, source, level, days, query.trim());
|
|
||||||
setErrors(result.errors);
|
|
||||||
setErrorsTotal(result.total);
|
|
||||||
setSources(result.sources);
|
|
||||||
setLevels(result.levels);
|
|
||||||
setSourceCounts(result.source_counts || {});
|
|
||||||
setLevelCounts(result.level_counts || {});
|
|
||||||
} catch {
|
|
||||||
setErrors([]);
|
|
||||||
setSourceCounts({});
|
|
||||||
setLevelCounts({});
|
|
||||||
} finally {
|
|
||||||
setErrorsLoading(false);
|
|
||||||
}
|
|
||||||
}, [days, level, query, source]);
|
|
||||||
|
|
||||||
const fetchSystemStatus = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setSystemStatus(await getSystemStatusAPI());
|
|
||||||
} catch {
|
|
||||||
setSystemStatus(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user?.role === "admin") {
|
|
||||||
fetchScanData();
|
|
||||||
fetchSystemStatus();
|
|
||||||
}
|
|
||||||
}, [user, days, fetchScanData, fetchSystemStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user?.role === "admin" && tab === "errors") {
|
|
||||||
fetchErrors();
|
|
||||||
}
|
|
||||||
}, [user, tab, fetchErrors]);
|
|
||||||
|
|
||||||
if (user?.role !== "admin") {
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6">
|
|
||||||
<p className="text-sm text-text-muted">需要管理员权限</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSelectSession(session: string) {
|
|
||||||
setSelectedSession(session);
|
|
||||||
setScanLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await getScanLogsAPI(session, days, 140);
|
|
||||||
setScanLogs(result.logs);
|
|
||||||
const observationData = await getResearchObservationsAPI(session, days, 80);
|
|
||||||
setObservations(observationData.observations);
|
|
||||||
setReasonCounts(observationData.reason_counts);
|
|
||||||
} finally {
|
|
||||||
setScanLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleClearErrors() {
|
|
||||||
const result = await clearErrorLogsAPI(30);
|
|
||||||
await fetchErrors();
|
|
||||||
await fetchSystemStatus();
|
|
||||||
alert(`已清除 ${result.deleted} 条旧日志`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
|
||||||
<header className="flex flex-col gap-4 animate-fade-in-up md:flex-row md:items-end md:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-text-muted">Ops Center</div>
|
|
||||||
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">运行日志</h1>
|
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-text-secondary">
|
|
||||||
系统错误、接口异常、数据源失败和扫描漏斗集中在这里,方便快速定位异常来源。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={days}
|
|
||||||
onChange={(event) => setDays(Number(event.target.value))}
|
|
||||||
className="rounded-xl border border-border-default bg-surface-2 px-3 py-2 text-xs text-text-primary outline-none focus:ring-1 focus:ring-amber-500/30"
|
|
||||||
>
|
|
||||||
<option value={1}>1天</option>
|
|
||||||
<option value={3}>3天</option>
|
|
||||||
<option value={7}>7天</option>
|
|
||||||
<option value={14}>14天</option>
|
|
||||||
<option value={30}>30天</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={() => tab === "funnel" ? fetchScanData(selectedSession) : fetchErrors()}
|
|
||||||
className="rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-xs font-medium text-text-secondary transition-all hover:bg-surface-4 hover:text-text-primary"
|
|
||||||
>
|
|
||||||
刷新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
|
||||||
{[
|
|
||||||
{ key: "errors", label: "系统错误" },
|
|
||||||
{ key: "funnel", label: "筛选漏斗" },
|
|
||||||
].map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
onClick={() => setTab(item.key as OpsTab)}
|
|
||||||
className={`rounded-xl border px-4 py-2 text-sm font-medium transition-all ${
|
|
||||||
tab === item.key
|
|
||||||
? "border-amber-500/15 bg-amber-500/[0.08] text-amber-400"
|
|
||||||
: "border-transparent bg-surface-2 text-text-muted hover:bg-surface-3 hover:text-text-secondary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === "funnel" ? (
|
|
||||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[280px_1fr]">
|
|
||||||
<aside className="glass-card-static p-4 self-start">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-sm font-semibold text-text-primary">扫描批次</h2>
|
|
||||||
<span className="text-[10px] text-text-muted">{sessions.length}轮</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
{sessions.length === 0 ? (
|
|
||||||
<div className="rounded-xl border border-border-subtle bg-surface-1/70 p-5 text-sm text-text-muted">
|
|
||||||
暂无筛选日志,下一次扫描后会自动出现。
|
|
||||||
</div>
|
|
||||||
) : sessions.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.scan_session}
|
|
||||||
onClick={() => handleSelectSession(item.scan_session)}
|
|
||||||
className={`w-full rounded-xl border p-3 text-left transition-all ${
|
|
||||||
activeSession?.scan_session === item.scan_session
|
|
||||||
? "border-amber-500/20 bg-amber-500/[0.06]"
|
|
||||||
: "border-border-subtle bg-surface-1/70 hover:border-border-default hover:bg-surface-2"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="truncate text-xs font-semibold text-text-primary">{shortSession(item.scan_session)}</span>
|
|
||||||
<StatusPill status={item.status} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-[10px] text-text-muted">{formatDateTime(item.created_at)}</div>
|
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
|
||||||
<TinyMetric label="关口" value={item.stage_count} />
|
|
||||||
<TinyMetric label="保留" value={item.final_count} />
|
|
||||||
<TinyMetric label="过滤" value={item.drop_count} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="space-y-5">
|
|
||||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
||||||
<SummaryCard label="当前批次" value={activeSession ? shortSession(activeSession.scan_session) : "-"} sub={activeSession ? formatDateTime(activeSession.created_at) : "等待扫描"} />
|
|
||||||
<SummaryCard label="最终保留" value={finalLog?.output_count ?? activeSession?.final_count ?? 0} sub={finalLog?.summary || activeSession?.last_summary || "暂无结果"} tone="primary" />
|
|
||||||
<SummaryCard label="最近错误" value={systemStatus?.recent_errors ?? 0} sub="过去24小时" tone={(systemStatus?.recent_errors ?? 0) > 0 ? "danger" : "muted"} />
|
|
||||||
<SummaryCard label="扫描状态" value={systemStatus?.scan_running ? "扫描中" : "空闲"} sub={systemStatus?.scan_locked ? "任务锁占用" : "可接受下一轮任务"} tone={systemStatus?.scan_running ? "warning" : "muted"} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="glass-card-static p-4 md:p-5">
|
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold text-text-primary">筛选过程可视化</h2>
|
|
||||||
<p className="mt-1 text-xs text-text-muted">输入、输出、过滤量和关键摘要按筛选顺序归档。</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-mono tabular-nums text-text-muted">{activeSession?.scan_mode || "-"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{scanLoading ? (
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{[1, 2, 3, 4].map((item) => <div key={item} className="h-20 animate-shimmer rounded-xl bg-surface-2" />)}
|
|
||||||
</div>
|
|
||||||
) : sortedLogs.length === 0 ? (
|
|
||||||
<div className="mt-5 rounded-2xl border border-border-subtle bg-surface-1/70 p-10 text-center text-sm text-text-muted">
|
|
||||||
暂无筛选过程日志
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{sortedLogs.map((log, index) => (
|
|
||||||
<FunnelStage key={log.id} log={log} index={index} maxCount={maxCount} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="glass-card-static p-4 md:p-5">
|
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold text-text-primary">投研观察</h2>
|
|
||||||
<p className="mt-1 text-xs text-text-muted">候选股的主题、资金、角色、入场信号和最终淘汰原因。</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] text-text-muted">{observations.length} 条记录</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[260px_1fr]">
|
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
|
||||||
<div className="text-xs font-semibold text-text-primary">淘汰原因</div>
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{Object.entries(reasonCounts).slice(0, 8).map(([reason, count]) => (
|
|
||||||
<div key={reason} className="flex items-center justify-between gap-3 rounded-lg bg-surface-2 px-3 py-2">
|
|
||||||
<span className="truncate text-[11px] text-text-muted">{reason}</span>
|
|
||||||
<span className="font-mono text-xs tabular-nums text-text-secondary">{count}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{Object.keys(reasonCounts).length === 0 ? (
|
|
||||||
<div className="rounded-lg bg-surface-2 px-3 py-4 text-xs text-text-muted">暂无原因分布</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-border-subtle bg-surface-1/70">
|
|
||||||
{observations.length === 0 ? (
|
|
||||||
<div className="p-8 text-center text-sm text-text-muted">暂无投研观察记录</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-border-subtle">
|
|
||||||
{observations.slice(0, 12).map((item) => (
|
|
||||||
<ResearchRow key={item.id} item={item} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<section className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
||||||
<SummaryCard label="错误总数" value={errorsTotal} sub={`最近 ${days} 天`} tone={errorsTotal > 0 ? "danger" : "muted"} />
|
|
||||||
<SummaryCard label="严重错误" value={levelCounts.critical ?? 0} sub="critical 级别" tone={(levelCounts.critical ?? 0) > 0 ? "danger" : "muted"} />
|
|
||||||
<SummaryCard label="异常来源" value={Object.keys(sourceCounts).length} sub={topSourceLabel(sourceCounts)} tone="warning" />
|
|
||||||
<SummaryCard label="最近一次" value={latestError ? formatDateTime(latestError.created_at) : "-"} sub={latestError?.source || "暂无错误"} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="glass-card-static p-4">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold text-text-primary">系统错误日志</h2>
|
|
||||||
<p className="mt-1 text-xs text-text-muted">自动记录应用内 ERROR/CRITICAL 日志,并保留手动上报的数据源和后台任务异常。</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") fetchErrors();
|
|
||||||
}}
|
|
||||||
placeholder="搜索消息、详情或来源"
|
|
||||||
className="w-full rounded-lg border border-border-default bg-surface-2 px-3 py-1.5 text-xs text-text-primary outline-none focus:ring-1 focus:ring-amber-500/30 md:w-48"
|
|
||||||
/>
|
|
||||||
<select value={source} onChange={(event) => setSource(event.target.value)} className="rounded-lg border border-border-default bg-surface-2 px-3 py-1.5 text-xs text-text-primary outline-none">
|
|
||||||
<option value="">全部来源</option>
|
|
||||||
{sources.map((item) => <option key={item} value={item}>{item}</option>)}
|
|
||||||
</select>
|
|
||||||
<select value={level} onChange={(event) => setLevel(event.target.value)} className="rounded-lg border border-border-default bg-surface-2 px-3 py-1.5 text-xs text-text-primary outline-none">
|
|
||||||
<option value="">全部级别</option>
|
|
||||||
{levels.map((item) => <option key={item} value={item}>{item}</option>)}
|
|
||||||
</select>
|
|
||||||
<button onClick={fetchErrors} className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-4 hover:text-text-primary">
|
|
||||||
查询
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setQuery("");
|
|
||||||
setSource("");
|
|
||||||
setLevel("");
|
|
||||||
}}
|
|
||||||
className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-1.5 text-xs text-text-muted hover:bg-surface-4 hover:text-text-secondary"
|
|
||||||
>
|
|
||||||
重置
|
|
||||||
</button>
|
|
||||||
<button onClick={handleClearErrors} className="rounded-lg border border-red-500/[0.08] bg-red-500/[0.04] px-3 py-1.5 text-xs text-red-400/70 hover:bg-red-500/[0.08] hover:text-red-400">
|
|
||||||
清除30天前
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr]">
|
|
||||||
<aside className="space-y-4">
|
|
||||||
<ErrorBreakdown title="来源分布" items={sourceCounts} active={source} onSelect={setSource} />
|
|
||||||
<ErrorBreakdown title="级别分布" items={levelCounts} active={level} onSelect={setLevel} labeler={statusLabel} />
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div className="glass-card-static overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
|
||||||
<span className="text-xs font-semibold text-text-primary">错误记录</span>
|
|
||||||
<span className="text-[10px] text-text-muted">显示 {errors.length} / {errorsTotal} 条</span>
|
|
||||||
</div>
|
|
||||||
{errorsLoading ? (
|
|
||||||
<div className="space-y-2 p-4">
|
|
||||||
{[1, 2, 3].map((item) => <div key={item} className="h-16 animate-shimmer rounded-xl bg-surface-2" />)}
|
|
||||||
</div>
|
|
||||||
) : errors.length === 0 ? (
|
|
||||||
<div className="p-10 text-center text-sm text-text-muted">暂无错误日志</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-border-subtle">
|
|
||||||
{errors.map((item) => (
|
|
||||||
<ErrorRow
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
expanded={expandedErrorId === item.id}
|
|
||||||
onToggle={() => setExpandedErrorId(expandedErrorId === item.id ? null : item.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ErrorBreakdown({
|
|
||||||
title,
|
|
||||||
items,
|
|
||||||
active,
|
|
||||||
onSelect,
|
|
||||||
labeler,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
items: Record<string, number>;
|
|
||||||
active: string;
|
|
||||||
onSelect: (value: string) => void;
|
|
||||||
labeler?: (value: string) => string;
|
|
||||||
}) {
|
|
||||||
const rows = Object.entries(items).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
||||||
const max = Math.max(...rows.map(([, count]) => count), 1);
|
|
||||||
return (
|
|
||||||
<div className="glass-card-static p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-xs font-semibold text-text-primary">{title}</h3>
|
|
||||||
{active ? (
|
|
||||||
<button onClick={() => onSelect("")} className="text-[10px] text-text-muted hover:text-text-secondary">
|
|
||||||
清除
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<div className="rounded-xl border border-border-subtle bg-surface-1/70 p-5 text-xs text-text-muted">暂无分布</div>
|
|
||||||
) : rows.map(([key, count]) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => onSelect(active === key ? "" : key)}
|
|
||||||
className={`w-full rounded-xl border px-3 py-2 text-left transition-all ${
|
|
||||||
active === key
|
|
||||||
? "border-amber-500/20 bg-amber-500/[0.07]"
|
|
||||||
: "border-border-subtle bg-surface-1/70 hover:bg-surface-2"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="truncate text-xs text-text-secondary">{labeler ? labeler(key) : key}</span>
|
|
||||||
<span className="font-mono text-xs tabular-nums text-text-primary">{count}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-bg-primary/60">
|
|
||||||
<div className="h-full rounded-full bg-amber-400/70" style={{ width: `${Math.max(6, Math.round((count / max) * 100))}%` }} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ErrorRow({ item, expanded, onToggle }: { item: ErrorLog; expanded: boolean; onToggle: () => void }) {
|
|
||||||
return (
|
|
||||||
<div className="px-4 py-3">
|
|
||||||
<button className="w-full text-left" onClick={onToggle}>
|
|
||||||
<div className="grid gap-2 md:grid-cols-[88px_160px_1fr_140px] md:items-center">
|
|
||||||
<StatusPill status={item.level} />
|
|
||||||
<span className="truncate font-mono text-xs text-text-muted">{item.source}</span>
|
|
||||||
<span className="truncate text-sm text-text-secondary">{item.message}</span>
|
|
||||||
<span className="text-[10px] font-mono tabular-nums text-text-muted md:text-right">{formatDateTime(item.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{expanded ? (
|
|
||||||
<div className="mt-3 space-y-3">
|
|
||||||
<div className="rounded-xl border border-border-subtle bg-surface-1 p-3">
|
|
||||||
<div className="text-[10px] uppercase tracking-wider text-text-muted">Message</div>
|
|
||||||
<div className="mt-2 break-words text-sm leading-6 text-text-secondary">{item.message}</div>
|
|
||||||
</div>
|
|
||||||
{item.detail ? (
|
|
||||||
<pre className="max-h-80 overflow-auto rounded-xl border border-border-subtle bg-bg-primary/70 p-3 text-xs leading-6 text-text-muted whitespace-pre-wrap">
|
|
||||||
{item.detail}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-xl border border-border-subtle bg-surface-1 p-3 text-xs text-text-muted">没有堆栈详情</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FunnelStage({ log, index, maxCount }: { log: ScanProcessLog; index: number; maxCount: number }) {
|
|
||||||
const outputWidth = Math.max(4, Math.round((log.output_count / maxCount) * 100));
|
|
||||||
const dropWidth = Math.max(0, Math.round((log.filtered_count / maxCount) * 100));
|
|
||||||
const detailItems = extractDetailItems(log);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[42px_1fr_260px] lg:items-center">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-border-subtle bg-surface-2 font-mono text-xs tabular-nums text-text-secondary">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<h3 className="text-sm font-semibold text-text-primary">{log.stage_label}</h3>
|
|
||||||
<StatusPill status={log.status} />
|
|
||||||
<span className="text-[10px] font-mono tabular-nums text-text-muted">{formatDateTime(log.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs leading-5 text-text-secondary">{log.summary || "暂无摘要"}</p>
|
|
||||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-surface-2">
|
|
||||||
<div className="flex h-full">
|
|
||||||
<div className="h-full bg-amber-400/70" style={{ width: `${outputWidth}%` }} />
|
|
||||||
<div className="h-full bg-red-400/40" style={{ width: `${dropWidth}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<TinyMetric label="输入" value={log.input_count} />
|
|
||||||
<TinyMetric label="输出" value={log.output_count} />
|
|
||||||
<TinyMetric label="过滤" value={log.filtered_count} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{detailItems.length > 0 ? (
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{detailItems.map((item) => (
|
|
||||||
<span key={item} className="rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-muted">
|
|
||||||
{item}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractDetailItems(log: ScanProcessLog) {
|
|
||||||
const detail = log.detail || {};
|
|
||||||
if (log.stage === "candidate_recall") {
|
|
||||||
const routes = detail.route_counts as Record<string, number> | undefined;
|
|
||||||
return routes ? Object.entries(routes).map(([key, value]) => `${routeLabel(key)} ${value}`) : [];
|
|
||||||
}
|
|
||||||
if (log.stage === "rule_scoring") {
|
|
||||||
const skipped = detail.skipped_counts as Record<string, number> | undefined;
|
|
||||||
const signals = detail.signal_counts as Record<string, number> | undefined;
|
|
||||||
return [
|
|
||||||
...(skipped ? Object.entries(skipped).filter(([, value]) => value > 0).map(([key, value]) => `${skipLabel(key)} ${value}`) : []),
|
|
||||||
...(signals ? Object.entries(signals).filter(([, value]) => value > 0).map(([key, value]) => `${signalLabel(key)} ${value}`) : []),
|
|
||||||
].slice(0, 10);
|
|
||||||
}
|
|
||||||
if (log.stage === "final_filter") {
|
|
||||||
const actions = detail.action_counts as Record<string, number> | undefined;
|
|
||||||
const reasons = detail.elimination_reasons as Record<string, number> | undefined;
|
|
||||||
return [
|
|
||||||
...(actions ? Object.entries(actions).map(([key, value]) => `${key} ${value}`) : []),
|
|
||||||
...(reasons ? Object.entries(reasons).slice(0, 6).map(([key, value]) => `${key} ${value}`) : []),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (log.stage === "theme_selection") {
|
|
||||||
const themes = detail.themes as Array<{ name?: string; heat_score?: number }> | undefined;
|
|
||||||
return themes?.slice(0, 5).map((item) => `${item.name || "主题"} ${Math.round(Number(item.heat_score || 0))}`) || [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResearchRow({ item }: { item: ResearchObservation }) {
|
|
||||||
return (
|
|
||||||
<article className="grid gap-3 px-4 py-3 lg:grid-cols-[160px_90px_1fr_300px] lg:items-center">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-semibold text-text-primary">{item.name}</div>
|
|
||||||
<div className="mt-1 font-mono text-[10px] text-text-muted">{item.ts_code}</div>
|
|
||||||
</div>
|
|
||||||
<div className="font-mono text-lg font-bold tabular-nums text-amber-400">{item.final_score.toFixed(1)}</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
<span className="rounded-md border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">{item.theme_name || "未归类"}</span>
|
|
||||||
<span className="rounded-md border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">{item.stock_role || "候选"}</span>
|
|
||||||
<span className="rounded-md border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">{signalLabel(item.entry_signal_type)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs leading-5 text-text-muted line-clamp-2">{item.elimination_reason || "待确认"}</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-5 gap-1.5">
|
|
||||||
<ScorePill label="催化" value={item.catalyst_score} />
|
|
||||||
<ScorePill label="题材" value={item.theme_money_score} />
|
|
||||||
<ScorePill label="资金" value={item.stock_money_score} />
|
|
||||||
<ScorePill label="角色" value={item.emotion_role_score} />
|
|
||||||
<ScorePill label="时机" value={item.timing_score} />
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScorePill({ label, value }: { label: string; value: number }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg bg-surface-2 px-2 py-1.5 text-center">
|
|
||||||
<div className="text-[9px] text-text-muted/60">{label}</div>
|
|
||||||
<div className="mt-0.5 font-mono text-[11px] font-semibold tabular-nums text-text-secondary">{Math.round(value)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SummaryCard({ label, value, sub, tone = "muted" }: { label: string; value: string | number; sub: string; tone?: "primary" | "warning" | "danger" | "muted" }) {
|
|
||||||
const valueClass = tone === "primary" ? "text-amber-400" : tone === "warning" ? "text-amber-300" : tone === "danger" ? "text-red-400" : "text-text-primary";
|
|
||||||
return (
|
|
||||||
<div className="glass-card-static p-4">
|
|
||||||
<div className="text-[10px] text-text-muted">{label}</div>
|
|
||||||
<div className={`mt-2 truncate text-xl font-bold tabular-nums ${valueClass}`}>{value}</div>
|
|
||||||
<div className="mt-1 line-clamp-1 text-[11px] text-text-muted">{sub}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TinyMetric({ 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/60">{label}</div>
|
|
||||||
<div className="mt-0.5 truncate font-mono text-[11px] font-semibold tabular-nums text-text-secondary">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusPill({ status }: { status: string }) {
|
|
||||||
const normalized = status.toLowerCase();
|
|
||||||
const className = normalized === "ok"
|
|
||||||
? "border-emerald-500/15 bg-emerald-500/[0.06] text-emerald-400"
|
|
||||||
: normalized === "warning" || normalized === "empty"
|
|
||||||
? "border-amber-500/15 bg-amber-500/[0.07] text-amber-400"
|
|
||||||
: "border-red-500/15 bg-red-500/[0.07] text-red-400";
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex w-fit items-center rounded-md border px-2 py-0.5 text-[10px] font-medium ${className}`}>
|
|
||||||
{statusLabel(status)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
|
||||||
const normalized = status.toLowerCase();
|
|
||||||
if (normalized === "ok") return "正常";
|
|
||||||
if (normalized === "warning") return "警告";
|
|
||||||
if (normalized === "empty") return "空结果";
|
|
||||||
if (normalized === "error") return "错误";
|
|
||||||
if (normalized === "critical") return "严重";
|
|
||||||
return status || "未知";
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeLabel(key: string) {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
sector_recall: "主线召回",
|
|
||||||
trend_scan: "趋势召回",
|
|
||||||
intraday_active: "盘中异动",
|
|
||||||
realtime_market: "全市场异动",
|
|
||||||
};
|
|
||||||
return map[key] || key;
|
|
||||||
}
|
|
||||||
|
|
||||||
function skipLabel(key: string) {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
missing_code: "缺代码",
|
|
||||||
kline_empty: "K线不足",
|
|
||||||
stale_kline: "K线过期",
|
|
||||||
exception: "评分异常",
|
|
||||||
};
|
|
||||||
return map[key] || key;
|
|
||||||
}
|
|
||||||
|
|
||||||
function signalLabel(key: string) {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
breakout: "突破",
|
|
||||||
breakout_confirm: "确认",
|
|
||||||
pullback: "回踩",
|
|
||||||
launch: "启动",
|
|
||||||
reversal: "反转",
|
|
||||||
none: "无信号",
|
|
||||||
};
|
|
||||||
return map[key] || key;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortSession(session: string) {
|
|
||||||
if (!session) return "-";
|
|
||||||
return session.length > 20 ? `${session.slice(0, 10)}...${session.slice(-6)}` : session;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(value: string) {
|
|
||||||
if (!value) return "-";
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
|
|
||||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function topSourceLabel(counts: Record<string, number>) {
|
|
||||||
const [source, count] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0] || [];
|
|
||||||
return source ? `${source} ${count} 条` : "暂无来源";
|
|
||||||
}
|
|
||||||
@ -1,613 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
|
||||||
import {
|
|
||||||
createInviteCodeAPI,
|
|
||||||
dataResetAPI,
|
|
||||||
disableUserAPI,
|
|
||||||
getDataStatsAPI,
|
|
||||||
listInviteCodesAPI,
|
|
||||||
listUsersAPI,
|
|
||||||
resetPasswordAPI,
|
|
||||||
toggleInviteCodeAPI,
|
|
||||||
type DataStats,
|
|
||||||
type InviteCodeItem,
|
|
||||||
type UserItem,
|
|
||||||
} from "@/lib/api";
|
|
||||||
|
|
||||||
type Section = "access" | "data";
|
|
||||||
type ResetMode = "low_score" | "date_range" | "recommendations" | "market_cache" | "diagnostics" | "logs" | "all";
|
|
||||||
|
|
||||||
const RESET_OPTIONS: Array<{ key: ResetMode; title: string; scope: string; danger: "low" | "medium" | "high" }> = [
|
|
||||||
{ key: "low_score", title: "清理低分推荐", scope: "删除评分低于 60 的推荐及其跟踪,保留有效复盘样本。", danger: "low" },
|
|
||||||
{ key: "date_range", title: "按日期归档清理", scope: "删除指定日期之前的推荐、跟踪、市场缓存和诊断记录。", danger: "medium" },
|
|
||||||
{ key: "recommendations", title: "清空推荐闭环", scope: "删除推荐池和推荐跟踪,策略复盘会失去历史样本。", danger: "high" },
|
|
||||||
{ key: "market_cache", title: "清空市场缓存", scope: "删除板块热度和市场温度,下次扫描会重新生成。", danger: "medium" },
|
|
||||||
{ key: "diagnostics", title: "清空诊断记录", scope: "删除单股诊断和自选股分析,不影响推荐池。", danger: "medium" },
|
|
||||||
{ key: "logs", title: "清空运行日志", scope: "删除错误日志、扫描过程和候选观察记录。", danger: "medium" },
|
|
||||||
{ key: "all", title: "重置业务数据", scope: "删除推荐、跟踪、市场缓存和诊断记录,用户和邀请码保留。", danger: "high" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
const { user: currentUser } = useAuth();
|
|
||||||
const [section, setSection] = useState<Section>("access");
|
|
||||||
const [users, setUsers] = useState<UserItem[]>([]);
|
|
||||||
const [inviteCodes, setInviteCodes] = useState<InviteCodeItem[]>([]);
|
|
||||||
const [dataStats, setDataStats] = useState<DataStats | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
|
|
||||||
const [showInviteDialog, setShowInviteDialog] = useState(false);
|
|
||||||
const [inviteCode, setInviteCode] = useState("");
|
|
||||||
const [inviteDescription, setInviteDescription] = useState("");
|
|
||||||
const [inviteMaxUses, setInviteMaxUses] = useState("10");
|
|
||||||
const [createdInviteCode, setCreatedInviteCode] = useState<string | null>(null);
|
|
||||||
const [creatingInvite, setCreatingInvite] = useState(false);
|
|
||||||
|
|
||||||
const [resetResult, setResetResult] = useState<{ email: string; password: string } | null>(null);
|
|
||||||
const [copiedKey, setCopiedKey] = useState("");
|
|
||||||
|
|
||||||
const [resetMode, setResetMode] = useState<ResetMode>("low_score");
|
|
||||||
const [beforeDate, setBeforeDate] = useState("");
|
|
||||||
const [confirmReset, setConfirmReset] = useState(false);
|
|
||||||
const [resetLoading, setResetLoading] = useState(false);
|
|
||||||
|
|
||||||
const loadAll = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [userRows, inviteRows, stats] = await Promise.all([
|
|
||||||
listUsersAPI(),
|
|
||||||
listInviteCodesAPI(),
|
|
||||||
getDataStatsAPI(),
|
|
||||||
]);
|
|
||||||
setUsers(userRows);
|
|
||||||
setInviteCodes(inviteRows);
|
|
||||||
setDataStats(stats);
|
|
||||||
} catch (err) {
|
|
||||||
setMessage(err instanceof Error ? err.message : "加载设置失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentUser?.role === "admin") loadAll();
|
|
||||||
}, [currentUser?.role, loadAll]);
|
|
||||||
|
|
||||||
const userSummary = useMemo(() => {
|
|
||||||
const active = users.filter((item) => item.is_active).length;
|
|
||||||
const admins = users.filter((item) => item.role === "admin").length;
|
|
||||||
return { active, disabled: users.length - active, admins };
|
|
||||||
}, [users]);
|
|
||||||
|
|
||||||
const inviteSummary = useMemo(() => {
|
|
||||||
const active = inviteCodes.filter((item) => item.is_active).length;
|
|
||||||
const remaining = inviteCodes.reduce((sum, item) => sum + Math.max((item.max_uses ?? 0) - (item.used_count ?? 0), 0), 0);
|
|
||||||
return { active, remaining };
|
|
||||||
}, [inviteCodes]);
|
|
||||||
|
|
||||||
if (currentUser?.role !== "admin") {
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6">
|
|
||||||
<p className="text-text-muted text-sm">需要管理员权限</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInviteLink(code: string) {
|
|
||||||
if (typeof window === "undefined") return `/login?invite=${encodeURIComponent(code)}`;
|
|
||||||
return `${window.location.origin}/login?invite=${encodeURIComponent(code)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyText(text: string, key: string) {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
setCopiedKey(key);
|
|
||||||
window.setTimeout(() => setCopiedKey(""), 1800);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateInvite(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
const normalizedCode = inviteCode.trim().toUpperCase();
|
|
||||||
const maxUses = Number(inviteMaxUses);
|
|
||||||
if (!normalizedCode) {
|
|
||||||
setMessage("请输入邀请码");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(maxUses) || maxUses < 1) {
|
|
||||||
setMessage("邀请人数上限至少为 1");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreatingInvite(true);
|
|
||||||
setMessage("");
|
|
||||||
try {
|
|
||||||
const result = await createInviteCodeAPI(normalizedCode, inviteDescription.trim(), maxUses);
|
|
||||||
setCreatedInviteCode(result.code);
|
|
||||||
setInviteCode("");
|
|
||||||
setInviteDescription("");
|
|
||||||
setInviteMaxUses("10");
|
|
||||||
await loadAll();
|
|
||||||
} catch (err) {
|
|
||||||
setMessage(err instanceof Error ? err.message : "创建邀请码失败");
|
|
||||||
} finally {
|
|
||||||
setCreatingInvite(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDisable(userId: number) {
|
|
||||||
try {
|
|
||||||
await disableUserAPI(userId);
|
|
||||||
await loadAll();
|
|
||||||
} catch (err) {
|
|
||||||
setMessage(err instanceof Error ? err.message : "禁用用户失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResetPassword(userId: number) {
|
|
||||||
try {
|
|
||||||
const result = await resetPasswordAPI(userId);
|
|
||||||
setResetResult({ email: result.email, password: result.password });
|
|
||||||
await loadAll();
|
|
||||||
} catch (err) {
|
|
||||||
setMessage(err instanceof Error ? err.message : "重置密码失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToggleInvite(inviteId: number) {
|
|
||||||
try {
|
|
||||||
await toggleInviteCodeAPI(inviteId);
|
|
||||||
await loadAll();
|
|
||||||
} catch (err) {
|
|
||||||
setMessage(err instanceof Error ? err.message : "更新邀请码失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDataReset() {
|
|
||||||
setResetLoading(true);
|
|
||||||
setConfirmReset(false);
|
|
||||||
setMessage("");
|
|
||||||
try {
|
|
||||||
const result = await dataResetAPI(
|
|
||||||
resetMode,
|
|
||||||
resetMode === "date_range" ? beforeDate : undefined,
|
|
||||||
resetMode === "low_score" ? 60 : undefined,
|
|
||||||
);
|
|
||||||
const deletedText = Object.entries(result.deleted)
|
|
||||||
.filter(([, value]) => value > 0)
|
|
||||||
.map(([key, value]) => `${formatDeletedKey(key)} ${value} 条`)
|
|
||||||
.join(",");
|
|
||||||
setMessage(deletedText ? `已清理:${deletedText}` : "没有需要清理的数据");
|
|
||||||
await loadAll();
|
|
||||||
} catch (err) {
|
|
||||||
setMessage(err instanceof Error ? err.message : "数据维护失败");
|
|
||||||
} finally {
|
|
||||||
setResetLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedReset = RESET_OPTIONS.find((item) => item.key === resetMode) ?? RESET_OPTIONS[0];
|
|
||||||
|
|
||||||
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="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">Admin</div>
|
|
||||||
<h1 className="mt-2 text-xl font-semibold tracking-tight">管理设置</h1>
|
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-text-secondary">
|
|
||||||
管理注册准入、账号状态和业务数据生命周期。运行日志、数据源健康和任务中心继续保留在侧边栏独立入口。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
|
||||||
{[
|
|
||||||
{ key: "access", label: "账号准入" },
|
|
||||||
{ key: "data", label: "数据维护" },
|
|
||||||
].map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
onClick={() => setSection(item.key as Section)}
|
|
||||||
className={`shrink-0 rounded-xl border px-4 py-2 text-sm font-medium transition-all ${
|
|
||||||
section === item.key
|
|
||||||
? "border-amber-500/20 bg-amber-500/[0.08] text-amber-400"
|
|
||||||
: "border-border-subtle bg-surface-1 text-text-muted hover:text-text-secondary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message ? (
|
|
||||||
<div className="rounded-xl border border-amber-500/15 bg-amber-500/[0.06] px-4 py-3 text-sm text-amber-300">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
||||||
<div className="h-40 glass-card-static animate-shimmer" />
|
|
||||||
<div className="h-40 glass-card-static animate-shimmer" />
|
|
||||||
<div className="h-40 glass-card-static animate-shimmer" />
|
|
||||||
</div>
|
|
||||||
) : section === "access" ? (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
<Metric label="活跃用户" value={userSummary.active} />
|
|
||||||
<Metric label="管理员" value={userSummary.admins} />
|
|
||||||
<Metric label="启用邀请码" value={inviteSummary.active} />
|
|
||||||
<Metric label="剩余邀请名额" value={inviteSummary.remaining} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_minmax(360px,0.8fr)] gap-5">
|
|
||||||
<section className="glass-card-static p-4 md:p-5">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-bold tracking-tight text-text-primary">用户列表</h2>
|
|
||||||
<p className="mt-1 text-xs text-text-muted">账号状态、角色、注册来源和密码重置。</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-text-muted">{users.length} 个账号</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[760px] text-left text-sm">
|
|
||||||
<thead className="text-[10px] uppercase tracking-wider text-text-muted">
|
|
||||||
<tr className="border-b border-border-subtle">
|
|
||||||
<th className="py-2 pr-3 font-medium">用户</th>
|
|
||||||
<th className="py-2 pr-3 font-medium">角色</th>
|
|
||||||
<th className="py-2 pr-3 font-medium">状态</th>
|
|
||||||
<th className="py-2 pr-3 font-medium">邀请码</th>
|
|
||||||
<th className="py-2 pr-3 font-medium">创建时间</th>
|
|
||||||
<th className="py-2 font-medium text-right">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((item) => (
|
|
||||||
<tr key={item.id} className="border-b border-border-subtle/70 last:border-0">
|
|
||||||
<td className="py-3 pr-3">
|
|
||||||
<div className="font-medium text-text-primary">{item.email || item.username}</div>
|
|
||||||
<div className="mt-0.5 font-mono text-[11px] text-text-muted">#{item.id}</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 pr-3"><Badge tone={item.role === "admin" ? "amber" : "muted"}>{item.role}</Badge></td>
|
|
||||||
<td className="py-3 pr-3"><Badge tone={item.is_active ? "green" : "red"}>{item.is_active ? "启用" : "禁用"}</Badge></td>
|
|
||||||
<td className="py-3 pr-3 font-mono text-xs text-text-secondary">{item.invite_code_used || "-"}</td>
|
|
||||||
<td className="py-3 pr-3 text-xs text-text-muted">{formatDateTime(item.created_at)}</td>
|
|
||||||
<td className="py-3 text-right">
|
|
||||||
{item.id !== currentUser.id ? (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button onClick={() => handleResetPassword(item.id)} className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary">
|
|
||||||
重置密码
|
|
||||||
</button>
|
|
||||||
{item.is_active ? (
|
|
||||||
<button onClick={() => handleDisable(item.id)} className="rounded-lg border border-red-500/10 bg-red-500/[0.04] px-3 py-1.5 text-xs text-red-400/80 hover:text-red-400">
|
|
||||||
禁用
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-text-muted">当前账号</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="glass-card-static p-4 md:p-5">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-bold tracking-tight text-text-primary">邀请码</h2>
|
|
||||||
<p className="mt-1 text-xs text-text-muted">复制注册链接后,用户打开会自动进入注册并填入邀请码。</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowInviteDialog(true);
|
|
||||||
setCreatedInviteCode(null);
|
|
||||||
setMessage("");
|
|
||||||
}}
|
|
||||||
className="rounded-xl border border-amber-500/20 bg-amber-500/[0.08] px-3 py-2 text-xs font-semibold text-amber-400 hover:bg-amber-500/[0.12]"
|
|
||||||
>
|
|
||||||
新建
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{inviteCodes.length ? inviteCodes.map((item) => (
|
|
||||||
<InviteCard
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
link={buildInviteLink(item.code)}
|
|
||||||
copiedKey={copiedKey}
|
|
||||||
onCopy={copyText}
|
|
||||||
onToggle={() => handleToggleInvite(item.id)}
|
|
||||||
/>
|
|
||||||
)) : (
|
|
||||||
<div className="rounded-xl border border-border-subtle bg-surface-1/70 p-8 text-center text-sm text-text-muted">暂无邀请码</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_420px] gap-5">
|
|
||||||
<section className="glass-card-static p-4 md:p-5">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-bold tracking-tight text-text-primary">数据资产</h2>
|
|
||||||
<p className="mt-1 text-xs text-text-muted">这里管理的是系统运行产生的业务数据,不管理用户账号本身。</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<DataBucket title="推荐闭环" description="推荐池、入场价、跟踪收益、策略复盘样本。" stats={[
|
|
||||||
["推荐", dataStats?.recommendations ?? 0],
|
|
||||||
["跟踪", dataStats?.tracking ?? 0],
|
|
||||||
["低分", dataStats?.low_score_count ?? 0],
|
|
||||||
]} />
|
|
||||||
<DataBucket title="市场缓存" description="市场温度和板块热度,扫描任务可重新生成。" stats={[
|
|
||||||
["市场温度", dataStats?.market_temperature ?? 0],
|
|
||||||
["板块热度", dataStats?.sector_heat ?? 0],
|
|
||||||
]} />
|
|
||||||
<DataBucket title="诊断沉淀" description="单股诊断和自选股分析记录,用于回看历史判断。" stats={[
|
|
||||||
["单股诊断", dataStats?.stock_diagnoses ?? 0],
|
|
||||||
["自选分析", dataStats?.watchlist_analyses ?? 0],
|
|
||||||
]} />
|
|
||||||
<DataBucket title="系统日志" description="错误日志、扫描过程、候选观察,便于排查问题。" stats={[
|
|
||||||
["错误日志", dataStats?.error_logs ?? 0],
|
|
||||||
["扫描日志", dataStats?.scan_logs ?? 0],
|
|
||||||
]} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 rounded-xl border border-border-subtle bg-surface-1/60 px-4 py-3 text-xs text-text-muted">
|
|
||||||
当前推荐记录日期范围:{dataStats?.earliest_date || "-"} 到 {dataStats?.latest_date || "-"}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="glass-card-static p-4 md:p-5">
|
|
||||||
<h2 className="text-base font-bold tracking-tight text-text-primary">维护动作</h2>
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
{RESET_OPTIONS.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
onClick={() => {
|
|
||||||
setResetMode(item.key);
|
|
||||||
setConfirmReset(false);
|
|
||||||
setMessage("");
|
|
||||||
}}
|
|
||||||
className={`w-full rounded-xl border px-3 py-3 text-left transition-all ${
|
|
||||||
resetMode === item.key
|
|
||||||
? "border-amber-500/20 bg-amber-500/[0.07]"
|
|
||||||
: "border-border-subtle bg-surface-1/70 hover:bg-surface-2"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-sm font-semibold text-text-primary">{item.title}</span>
|
|
||||||
<Badge tone={item.danger === "high" ? "red" : item.danger === "medium" ? "amber" : "green"}>
|
|
||||||
{item.danger === "high" ? "高风险" : item.danger === "medium" ? "谨慎" : "常规"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs leading-5 text-text-muted">{item.scope}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{resetMode === "date_range" ? (
|
|
||||||
<div className="mt-4">
|
|
||||||
<label className="text-xs text-text-muted mb-1.5 block">删除此日期之前的数据</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={beforeDate}
|
|
||||||
onChange={(event) => setBeforeDate(event.target.value)}
|
|
||||||
className="w-full rounded-xl border border-border-default bg-surface-2 px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-4 rounded-xl border border-border-subtle bg-surface-1/60 px-3 py-3 text-xs leading-5 text-text-secondary">
|
|
||||||
将执行:{selectedReset.scope}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{confirmReset ? (
|
|
||||||
<div className="mt-4 rounded-xl border border-red-500/15 bg-red-500/[0.05] p-3">
|
|
||||||
<div className="text-sm font-semibold text-red-300">确认执行这个数据维护动作?</div>
|
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
<button onClick={handleDataReset} disabled={resetLoading} className="rounded-lg bg-red-500/20 px-3 py-2 text-xs font-semibold text-red-300 disabled:opacity-50">
|
|
||||||
{resetLoading ? "执行中" : "确认执行"}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setConfirmReset(false)} className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-2 text-xs text-text-secondary">
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmReset(true)}
|
|
||||||
disabled={resetMode === "date_range" && !beforeDate}
|
|
||||||
className="mt-4 w-full rounded-xl border border-red-500/10 bg-red-500/[0.04] px-3 py-2.5 text-sm font-semibold text-red-400/80 hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
|
||||||
执行维护
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showInviteDialog ? (
|
|
||||||
<Modal onClose={() => setShowInviteDialog(false)}>
|
|
||||||
{createdInviteCode ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-base font-semibold text-text-primary">邀请码创建成功</h3>
|
|
||||||
<div className="rounded-xl border border-border-subtle bg-surface-1 p-3">
|
|
||||||
<div className="text-xs text-text-muted">注册链接</div>
|
|
||||||
<div className="mt-2 break-all font-mono text-xs text-text-secondary">{buildInviteLink(createdInviteCode)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<button onClick={() => copyText(createdInviteCode, "created-code")} className="rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-sm text-text-secondary hover:text-text-primary">
|
|
||||||
{copiedKey === "created-code" ? "已复制" : "复制邀请码"}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => copyText(buildInviteLink(createdInviteCode), "created-link")} className="rounded-xl border border-amber-500/20 bg-amber-500/[0.08] px-3 py-2 text-sm font-semibold text-amber-400">
|
|
||||||
{copiedKey === "created-link" ? "已复制" : "复制链接"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setShowInviteDialog(false)} className="w-full rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-sm text-text-secondary">
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleCreateInvite} className="space-y-3">
|
|
||||||
<h3 className="text-base font-semibold text-text-primary">新建邀请码</h3>
|
|
||||||
<input value={inviteCode} onChange={(event) => setInviteCode(event.target.value)} placeholder="邀请码,例如 ASTOCK-VIP-01" className="w-full rounded-xl border border-border-default bg-surface-2 px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30" />
|
|
||||||
<input value={inviteDescription} onChange={(event) => setInviteDescription(event.target.value)} placeholder="说明,例如 内测第一批" className="w-full rounded-xl border border-border-default bg-surface-2 px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30" />
|
|
||||||
<input type="number" min="1" value={inviteMaxUses} onChange={(event) => setInviteMaxUses(event.target.value)} placeholder="邀请人数上限" className="w-full rounded-xl border border-border-default bg-surface-2 px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30" />
|
|
||||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
|
||||||
<button type="button" onClick={() => setShowInviteDialog(false)} className="rounded-xl border border-border-subtle bg-surface-2 px-3 py-2.5 text-sm text-text-secondary">
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button type="submit" disabled={creatingInvite} className="rounded-xl border border-amber-500/20 bg-amber-500/[0.08] px-3 py-2.5 text-sm font-semibold text-amber-400 disabled:opacity-50">
|
|
||||||
{creatingInvite ? "创建中" : "创建"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{resetResult ? (
|
|
||||||
<Modal onClose={() => setResetResult(null)}>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-base font-semibold text-text-primary">密码已重置</h3>
|
|
||||||
<div className="rounded-xl border border-border-subtle bg-surface-1 p-3 space-y-2">
|
|
||||||
<div className="flex justify-between gap-3 text-sm"><span className="text-text-muted">邮箱</span><span className="text-text-primary">{resetResult.email}</span></div>
|
|
||||||
<div className="flex justify-between gap-3 text-sm"><span className="text-text-muted">新密码</span><span className="font-mono text-amber-400">{resetResult.password}</span></div>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => copyText(`邮箱:${resetResult.email}\n密码:${resetResult.password}`, "password")} className="w-full rounded-xl border border-amber-500/20 bg-amber-500/[0.08] px-3 py-2.5 text-sm font-semibold text-amber-400">
|
|
||||||
{copiedKey === "password" ? "已复制" : "复制登录信息"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InviteCard({
|
|
||||||
item,
|
|
||||||
link,
|
|
||||||
copiedKey,
|
|
||||||
onCopy,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
item: InviteCodeItem;
|
|
||||||
link: string;
|
|
||||||
copiedKey: string;
|
|
||||||
onCopy: (text: string, key: string) => void;
|
|
||||||
onToggle: () => void;
|
|
||||||
}) {
|
|
||||||
const remaining = Math.max(item.max_uses - item.used_count, 0);
|
|
||||||
const exhausted = item.max_uses > 0 && item.used_count >= item.max_uses;
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/60 p-3">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-mono text-sm font-semibold text-text-primary">{item.code}</span>
|
|
||||||
<Badge tone={item.is_active ? "green" : "muted"}>{item.is_active ? "启用" : "停用"}</Badge>
|
|
||||||
{exhausted ? <Badge tone="red">已用完</Badge> : null}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-text-muted">{item.description || "无说明"}</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={onToggle} className="shrink-0 rounded-lg border border-border-subtle bg-surface-2 px-2.5 py-1.5 text-xs text-text-secondary hover:text-text-primary">
|
|
||||||
{item.is_active ? "停用" : "启用"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
|
||||||
<MiniStat label="已用" value={item.used_count} />
|
|
||||||
<MiniStat label="上限" value={item.max_uses} />
|
|
||||||
<MiniStat label="剩余" value={remaining} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 rounded-xl bg-bg-primary/50 px-3 py-2">
|
|
||||||
<div className="text-[10px] text-text-muted">注册链接</div>
|
|
||||||
<div className="mt-1 truncate font-mono text-[11px] text-text-secondary">{link}</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
|
||||||
<button onClick={() => onCopy(item.code, `code-${item.id}`)} className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-2 text-xs text-text-secondary hover:text-text-primary">
|
|
||||||
{copiedKey === `code-${item.id}` ? "已复制" : "复制码"}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => onCopy(link, `link-${item.id}`)} className="rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-xs font-semibold text-amber-400">
|
|
||||||
{copiedKey === `link-${item.id}` ? "已复制" : "复制链接"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataBucket({ title, description, stats }: { title: string; description: string; stats: Array<[string, number]> }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/60 p-4">
|
|
||||||
<div className="text-sm font-semibold text-text-primary">{title}</div>
|
|
||||||
<div className="mt-1 text-xs leading-5 text-text-muted">{description}</div>
|
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
|
||||||
{stats.map(([label, value]) => (
|
|
||||||
<MiniStat key={label} label={label} value={value} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Metric({ label, value }: { label: string; value: number }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-4 py-3">
|
|
||||||
<div className="text-[10px] uppercase tracking-wider text-text-muted">{label}</div>
|
|
||||||
<div className="mt-1 font-mono text-xl font-bold tabular-nums text-text-primary">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MiniStat({ label, value }: { label: string; value: number }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl bg-bg-primary/50 px-3 py-2">
|
|
||||||
<div className="text-[10px] text-text-muted">{label}</div>
|
|
||||||
<div className="mt-1 font-mono text-sm font-semibold tabular-nums text-text-primary">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Badge({ children, tone }: { children: React.ReactNode; tone: "green" | "red" | "amber" | "muted" }) {
|
|
||||||
const className =
|
|
||||||
tone === "green"
|
|
||||||
? "border-emerald-500/15 bg-emerald-500/10 text-emerald-300"
|
|
||||||
: tone === "red"
|
|
||||||
? "border-red-500/15 bg-red-500/10 text-red-300"
|
|
||||||
: tone === "amber"
|
|
||||||
? "border-amber-500/15 bg-amber-500/10 text-amber-300"
|
|
||||||
: "border-border-subtle bg-surface-2 text-text-muted";
|
|
||||||
return <span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${className}`}>{children}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
|
||||||
<button aria-label="关闭" className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
||||||
<div className="relative mx-4 w-full max-w-md rounded-2xl border border-border-default bg-bg-card p-6 shadow-card">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(value: string | null) {
|
|
||||||
if (!value) return "-";
|
|
||||||
return new Date(value).toLocaleString("zh-CN");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDeletedKey(key: string) {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
recommendation_tracking: "跟踪记录",
|
|
||||||
tracking: "跟踪记录",
|
|
||||||
recommendations: "推荐记录",
|
|
||||||
sector_heat: "板块热度",
|
|
||||||
market_temperature: "市场温度",
|
|
||||||
stock_diagnoses: "单股诊断",
|
|
||||||
watchlist_analyses: "自选分析",
|
|
||||||
error_logs: "错误日志",
|
|
||||||
scan_process_logs: "扫描日志",
|
|
||||||
research_observations: "候选观察",
|
|
||||||
};
|
|
||||||
return labels[key] ?? key;
|
|
||||||
}
|
|
||||||
@ -1,628 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { fetchAPI, postAPI } from "@/lib/api";
|
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
|
||||||
import type {
|
|
||||||
AgentPatchPrompt,
|
|
||||||
PerformanceStats,
|
|
||||||
StrategyConfigCenter,
|
|
||||||
StrategyConfigChange,
|
|
||||||
StrategyConfigRecord,
|
|
||||||
StrategyAdjustment,
|
|
||||||
StrategyIterationReport,
|
|
||||||
StrategyStat,
|
|
||||||
} from "@/lib/api";
|
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
|
||||||
tighten: "收紧",
|
|
||||||
promote: "加强",
|
|
||||||
reduce: "降权",
|
|
||||||
keep: "保持",
|
|
||||||
observe: "观察",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StrategyPage() {
|
|
||||||
const { user, loading: authLoading } = useAuth();
|
|
||||||
const router = useRouter();
|
|
||||||
const [iteration, setIteration] = useState<StrategyIterationReport | null>(null);
|
|
||||||
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
|
||||||
const [configCenter, setConfigCenter] = useState<StrategyConfigCenter | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [actionMessage, setActionMessage] = useState("");
|
|
||||||
const [copiedPrompt, setCopiedPrompt] = useState("");
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const [iterationReport, perf, configs] = await Promise.all([
|
|
||||||
fetchAPI<StrategyIterationReport>("/api/market/strategy-iteration?limit=80").catch(() => null),
|
|
||||||
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
|
||||||
fetchAPI<StrategyConfigCenter>("/api/market/strategy-configs").catch(() => null),
|
|
||||||
]);
|
|
||||||
setIteration(iterationReport);
|
|
||||||
setPerformance(perf);
|
|
||||||
setConfigCenter(configs);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const generateIteration = useCallback(async () => {
|
|
||||||
setActionMessage("正在生成策略复盘...");
|
|
||||||
try {
|
|
||||||
const report = await postAPI<StrategyIterationReport>("/api/market/generate-strategy-iteration?limit=80");
|
|
||||||
const configs = await fetchAPI<StrategyConfigCenter>("/api/market/strategy-configs");
|
|
||||||
setIteration(report);
|
|
||||||
setConfigCenter(configs);
|
|
||||||
setActionMessage(report.auto_config_change ? "已生成复盘,并自动写入一条小幅配置调整。" : "已生成复盘,本次没有触发自动配置调整。");
|
|
||||||
} catch (e) {
|
|
||||||
setActionMessage(e instanceof Error ? e.message : "策略复盘生成失败");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const rollbackConfig = useCallback(async (strategyId: string) => {
|
|
||||||
setActionMessage(`正在回滚 ${strategyId}...`);
|
|
||||||
try {
|
|
||||||
await postAPI(`/api/market/strategy-configs/${strategyId}/rollback`);
|
|
||||||
await loadData();
|
|
||||||
setActionMessage(`${strategyId} 已回滚到上一版本。`);
|
|
||||||
} catch (e) {
|
|
||||||
setActionMessage(e instanceof Error ? e.message : "回滚失败");
|
|
||||||
}
|
|
||||||
}, [loadData]);
|
|
||||||
|
|
||||||
const copyPatchPrompt = useCallback(async (item: AgentPatchPrompt) => {
|
|
||||||
await navigator.clipboard.writeText(item.prompt);
|
|
||||||
setCopiedPrompt(item.title);
|
|
||||||
window.setTimeout(() => setCopiedPrompt(""), 1800);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authLoading && user?.role !== "admin") {
|
|
||||||
router.replace("/dashboard");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, [authLoading, router, user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (authLoading || user?.role !== "admin") return;
|
|
||||||
loadData();
|
|
||||||
}, [authLoading, loadData, user]);
|
|
||||||
|
|
||||||
const diagnosis = useMemo(() => buildCalibrationDiagnosis(iteration, performance), [iteration, performance]);
|
|
||||||
const safeWinRate = clampPercent(performance?.win_rate ?? 0);
|
|
||||||
|
|
||||||
if (authLoading || user?.role !== "admin" || loading) {
|
|
||||||
return (
|
|
||||||
<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="glass-card-static p-3 animate-fade-in-up flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-text-primary">配置化自我迭代</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{actionMessage ? <span className="text-xs text-amber-400">{actionMessage}</span> : null}
|
|
||||||
<button
|
|
||||||
onClick={generateIteration}
|
|
||||||
className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs font-semibold text-amber-400 transition-colors hover:bg-amber-500/15"
|
|
||||||
>
|
|
||||||
生成复盘并校准
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">系统当前判断</div>
|
|
||||||
<h2 className="mt-2 text-xl font-bold tracking-tight text-text-primary">{diagnosis.headline}</h2>
|
|
||||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary line-clamp-2">{diagnosis.detail}</p>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<DecisionList title="用途" items={diagnosis.useFor} tone="positive" />
|
|
||||||
<DecisionList title="边界" items={diagnosis.notFor} tone="risk" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 self-start">
|
|
||||||
<MetricCard label="复盘样本" value={iteration?.sample_size ?? 0} />
|
|
||||||
<MetricCard label="整体胜率" value={`${safeWinRate.toFixed(1)}%`} tone={safeWinRate >= 50 ? "up" : "down"} />
|
|
||||||
<MetricCard label="平均收益" value={`${(performance?.avg_return ?? 0) > 0 ? "+" : ""}${(performance?.avg_return ?? 0).toFixed(2)}%`} tone={(performance?.avg_return ?? 0) >= 0 ? "up" : "down"} />
|
|
||||||
<MetricCard label="平均回撤" value={`${(performance?.avg_max_drawdown ?? 0).toFixed(2)}%`} tone="risk" />
|
|
||||||
<MetricFact label="已跟踪" value={`${performance?.tracked ?? 0} 只`} />
|
|
||||||
<MetricFact label="页面角色" value="方法迭代" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{iteration ? (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px] gap-4">
|
|
||||||
<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="mt-2 text-sm leading-7 text-text-secondary">
|
|
||||||
{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-2xl border border-cyan-500/10 bg-cyan-500/[0.04] p-4 text-sm leading-7 text-cyan-400/85">
|
|
||||||
{iteration.ai_analysis}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<ReviewWindowsPanel windows={iteration.review_windows ?? []} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfigCenterPanel
|
|
||||||
configs={configCenter}
|
|
||||||
onRollback={rollbackConfig}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<SectionTitle title="下一轮系统指令" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{(iteration.adjustment_suggestions.length
|
|
||||||
? iteration.adjustment_suggestions
|
|
||||||
: [{ target: "推荐系统", action: "observe", reason: "等待更多跟踪样本后再调整策略权重。", confidence: "低" }]
|
|
||||||
).slice(0, 6).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="哪些策略更有效"
|
|
||||||
description="按策略分组"
|
|
||||||
stats={iteration.strategy_stats}
|
|
||||||
/>
|
|
||||||
<StatsPanel
|
|
||||||
title="哪些信号更有效"
|
|
||||||
description="按信号分组"
|
|
||||||
stats={iteration.signal_stats}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AgentPatchPromptPanel
|
|
||||||
prompts={iteration.agent_patch_prompts ?? []}
|
|
||||||
copiedTitle={copiedPrompt}
|
|
||||||
onCopy={copyPatchPrompt}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
|
||||||
<SectionTitle title="最近的失效模式" />
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
{iteration.failure_patterns.length ? (
|
|
||||||
iteration.failure_patterns.map((pattern, index) => (
|
|
||||||
<div key={index} className="rounded-2xl border border-border-subtle bg-surface-1 px-3 py-3 text-sm leading-6 text-text-secondary">
|
|
||||||
{pattern}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1 px-3 py-3 text-sm text-text-muted">
|
|
||||||
暂无明确失效模式。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="glass-card-static p-10 text-center">
|
|
||||||
<div className="text-text-muted text-sm">暂无系统校准数据</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCalibrationDiagnosis(
|
|
||||||
iteration: StrategyIterationReport | null,
|
|
||||||
performance: PerformanceStats | null
|
|
||||||
) {
|
|
||||||
const winRate = clampPercent(performance?.win_rate ?? 0);
|
|
||||||
const avgReturn = performance?.avg_return ?? 0;
|
|
||||||
const tracked = performance?.tracked ?? 0;
|
|
||||||
const headline =
|
|
||||||
tracked < 10
|
|
||||||
? "样本积累中"
|
|
||||||
: winRate >= 55 && avgReturn >= 0
|
|
||||||
? "方法有效"
|
|
||||||
: "方法退化";
|
|
||||||
|
|
||||||
const detail =
|
|
||||||
iteration?.summary ??
|
|
||||||
(tracked < 10
|
|
||||||
? "闭环样本不足,只看方向偏差。"
|
|
||||||
: "检查推荐方法偏差和下一轮调整。");
|
|
||||||
|
|
||||||
const useFor = [
|
|
||||||
"验证推荐兑现率。",
|
|
||||||
"识别有效策略和信号。",
|
|
||||||
"生成下一轮配置调整。",
|
|
||||||
];
|
|
||||||
|
|
||||||
const notFor = [
|
|
||||||
"不做盘中买卖决策。",
|
|
||||||
"不替代板块行情。",
|
|
||||||
"不展开单股长逻辑。",
|
|
||||||
];
|
|
||||||
|
|
||||||
return { headline, detail, useFor, notFor };
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampPercent(value: number) {
|
|
||||||
if (!Number.isFinite(value)) return 0;
|
|
||||||
return Math.max(0, Math.min(100, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function DecisionList({
|
|
||||||
title,
|
|
||||||
items,
|
|
||||||
tone,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
items: string[];
|
|
||||||
tone: "positive" | "risk";
|
|
||||||
}) {
|
|
||||||
const dotClass = tone === "positive" ? "bg-emerald-400" : "bg-amber-400";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
|
||||||
<div className="text-[11px] font-semibold text-text-secondary">{title}</div>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<div key={`${title}-${index}`} className="flex items-start gap-2 text-sm text-text-secondary">
|
|
||||||
<span className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${dotClass}`} />
|
|
||||||
<span className="leading-6">{item}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfigCenterPanel({
|
|
||||||
configs,
|
|
||||||
onRollback,
|
|
||||||
}: {
|
|
||||||
configs: StrategyConfigCenter | null;
|
|
||||||
onRollback: (strategyId: string) => void;
|
|
||||||
}) {
|
|
||||||
const strategies = configs?.strategies ?? [];
|
|
||||||
const prompts = configs?.prompts ?? [];
|
|
||||||
const changes = configs?.changes ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_420px] gap-4">
|
|
||||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<SectionTitle title="当前策略配置版本" />
|
|
||||||
<span className="text-xs text-text-muted">下一轮扫描直接读取</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{strategies.length ? strategies.map((item) => (
|
|
||||||
<StrategyConfigCard key={item.strategy_id} item={item} onRollback={onRollback} />
|
|
||||||
)) : (
|
|
||||||
<div className="text-sm text-text-muted">暂无配置数据。</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 border-t border-border-subtle pt-4">
|
|
||||||
<div className="text-[11px] font-semibold text-text-secondary">Prompt 版本</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{prompts.length ? prompts.map((item) => (
|
|
||||||
<span key={item.prompt_key} className="rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-muted">
|
|
||||||
{item.prompt_key} · v{item.version}
|
|
||||||
</span>
|
|
||||||
)) : (
|
|
||||||
<span className="text-xs text-text-muted">暂无 Prompt 配置。</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
|
||||||
<SectionTitle title="最近配置变更" />
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{changes.length ? changes.slice(0, 6).map((item) => (
|
|
||||||
<ConfigChangeRow key={item.id} item={item} />
|
|
||||||
)) : (
|
|
||||||
<div className="text-sm text-text-muted">暂无变更记录。</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StrategyConfigCard({ item, onRollback }: { item: StrategyConfigRecord; onRollback: (strategyId: string) => void }) {
|
|
||||||
const cfg = item.config;
|
|
||||||
const scoreWeights = cfg.score_weights as Record<string, number> | undefined;
|
|
||||||
const weightLabels: Record<string, string> = {
|
|
||||||
catalyst: "催化",
|
|
||||||
theme_money: "主题资金",
|
|
||||||
stock_money: "个股资金",
|
|
||||||
emotion_role: "情绪角色",
|
|
||||||
timing: "时机",
|
|
||||||
capital_momentum: "资金顺势",
|
|
||||||
supply_demand: "供需",
|
|
||||||
price_action: "价格行为",
|
|
||||||
trend: "趋势",
|
|
||||||
};
|
|
||||||
const weightText = scoreWeights
|
|
||||||
? Object.entries(scoreWeights).map(([key, value]) => `${weightLabels[key] ?? key}:${Number(value).toFixed(2)}`).join(" / ")
|
|
||||||
: "暂无权重";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-semibold text-text-primary">{item.strategy_id}</div>
|
|
||||||
<div className="mt-1 text-[11px] text-text-muted">v{item.version} · {item.source}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onRollback(item.strategy_id)}
|
|
||||||
className="shrink-0 rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-secondary transition-colors hover:border-amber-500/20 hover:text-amber-400"
|
|
||||||
>
|
|
||||||
回滚
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2 text-[10px]">
|
|
||||||
<StatCell label="买入阈值" value={String(cfg.buy_threshold ?? "-")} />
|
|
||||||
<StatCell label="最低分" value={String(cfg.min_score ?? "-")} />
|
|
||||||
<StatCell label="仓位上限" value={`${cfg.max_position_pct ?? "-"}%`} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 rounded-xl bg-surface-2/70 px-3 py-2 text-[11px] leading-5 text-text-muted">
|
|
||||||
{weightText}
|
|
||||||
</div>
|
|
||||||
{item.change_reason ? (
|
|
||||||
<div className="mt-2 text-xs leading-5 text-text-secondary">{item.change_reason}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfigChangeRow({ item }: { item: StrategyConfigChange }) {
|
|
||||||
const diffEntries = Object.entries(item.diff ?? {}).slice(0, 4);
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-text-primary">{item.strategy_id || item.prompt_key || "配置变更"}</div>
|
|
||||||
<div className="mt-1 text-[11px] text-text-muted">
|
|
||||||
{item.change_type} · v{item.base_version} → v{item.new_version}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="rounded-full border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
|
|
||||||
{item.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs leading-5 text-text-secondary">{item.reason || "暂无说明"}</div>
|
|
||||||
{diffEntries.length ? (
|
|
||||||
<div className="mt-2 space-y-1">
|
|
||||||
{diffEntries.map(([key, value]) => (
|
|
||||||
<div key={`${item.id}-${key}`} className="rounded-lg bg-surface-2/70 px-2 py-1 text-[10px] text-text-muted">
|
|
||||||
{key}: {formatUnknown(value.from)} → {formatUnknown(value.to)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewWindowsPanel({ windows }: { windows: NonNullable<StrategyIterationReport["review_windows"]> }) {
|
|
||||||
return (
|
|
||||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
|
||||||
<SectionTitle title="3/5/10 日复盘窗口" />
|
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
||||||
{windows.length ? windows.map((item) => (
|
|
||||||
<div key={item.window_days} className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
|
||||||
<div className="text-sm font-semibold text-text-primary">T+{item.window_days}</div>
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
|
||||||
<StatCell label="样本" value={item.count} />
|
|
||||||
<StatCell label="胜率" value={`${item.win_rate.toFixed(1)}%`} />
|
|
||||||
<StatCell label="收益" value={`${item.avg_return > 0 ? "+" : ""}${item.avg_return.toFixed(2)}%`} />
|
|
||||||
<StatCell label="目标率" value={`${item.hit_target_rate.toFixed(1)}%`} />
|
|
||||||
<StatCell label="止损率" value={`${item.hit_stop_rate.toFixed(1)}%`} />
|
|
||||||
<StatCell label="回撤" value={`${item.avg_max_drawdown.toFixed(1)}%`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)) : (
|
|
||||||
<div className="text-sm text-text-muted">暂无窗口样本。</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AgentPatchPromptPanel({
|
|
||||||
prompts,
|
|
||||||
copiedTitle,
|
|
||||||
onCopy,
|
|
||||||
}: {
|
|
||||||
prompts: AgentPatchPrompt[];
|
|
||||||
copiedTitle: string;
|
|
||||||
onCopy: (item: AgentPatchPrompt) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<SectionTitle title="Agent 修改提示词" />
|
|
||||||
<span className="text-xs text-text-muted">大幅策略改造先人工审查</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{prompts.length ? prompts.map((item) => (
|
|
||||||
<div key={item.title} className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-text-primary">{item.title}</div>
|
|
||||||
<div className="mt-1 text-xs text-text-muted">{item.evidence}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onCopy(item)}
|
|
||||||
className="shrink-0 rounded-xl border border-cyan-500/15 bg-cyan-500/[0.05] px-3 py-2 text-xs font-semibold text-cyan-400 transition-colors hover:bg-cyan-500/10"
|
|
||||||
>
|
|
||||||
{copiedTitle === item.title ? "已复制" : "复制提示词"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 rounded-xl bg-surface-2/70 p-3 text-xs leading-6 text-text-secondary">
|
|
||||||
{item.prompt}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{item.target_files.map((file) => (
|
|
||||||
<span key={file} className="rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-muted">
|
|
||||||
{file}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)) : (
|
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 text-sm text-text-muted">
|
|
||||||
当前样本不足或没有集中失效模式,暂不生成代码级改造提示词。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUnknown(value: unknown): string {
|
|
||||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
|
||||||
if (value == null) return "空";
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
return "复杂配置";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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-2xl 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="shrink-0 rounded-full border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
|
|
||||||
置信 {item.confidence}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-sm leading-6 text-text-secondary">{item.reason}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
tone,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
tone?: "up" | "down" | "risk";
|
|
||||||
}) {
|
|
||||||
const color = tone === "up" ? "text-red-400" : tone === "down" ? "text-emerald-400" : tone === "risk" ? "text-amber-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 MetricFact({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="glass-card-static p-4">
|
|
||||||
<div className="text-[10px] text-text-muted/60 mb-1">{label}</div>
|
|
||||||
<div className="text-xs font-semibold leading-5 text-text-secondary">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatsPanel({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
stats,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
stats: StrategyStat[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
|
||||||
<SectionTitle title={title} />
|
|
||||||
<div className="mt-1 text-xs text-text-muted">{description}</div>
|
|
||||||
<div className="space-y-2 mt-4">
|
|
||||||
{stats.length ? (
|
|
||||||
stats.slice(0, 6).map((stat) => (
|
|
||||||
<div key={stat.name} className="rounded-2xl 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
|
||||||
import { getTaskCenterAPI, type TaskCenterResult } from "@/lib/api";
|
|
||||||
|
|
||||||
export default function TasksPage() {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [data, setData] = useState<TaskCenterResult | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
setData(await getTaskCenterAPI());
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user?.role === "admin") loadData();
|
|
||||||
}, [user, loadData]);
|
|
||||||
|
|
||||||
const jobGroups = useMemo(() => {
|
|
||||||
const jobs = data?.jobs || [];
|
|
||||||
return {
|
|
||||||
news: jobs.filter((job) => job.id.startsWith("news")),
|
|
||||||
scan: jobs.filter((job) => job.id.includes("market") || job.id.includes("morning") || job.id.includes("afternoon") || job.id.includes("late") || job.id.includes("pre_") || job.id.includes("post_") || job.id.includes("close")),
|
|
||||||
review: jobs.filter((job) => job.id.includes("watchlist") || job.id.includes("strategy")),
|
|
||||||
};
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (user?.role !== "admin") {
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6">
|
|
||||||
<p className="text-sm text-text-muted">需要管理员权限</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
|
||||||
<header className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-text-muted">Task Center</div>
|
|
||||||
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">任务中心</h1>
|
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-text-secondary">
|
|
||||||
后台新闻采集、选股扫描、跟踪复盘和策略校准任务的运行视图。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={loadData} className="w-fit rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-xs font-medium text-text-secondary transition-all hover:bg-surface-4 hover:text-text-primary">
|
|
||||||
刷新
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
||||||
<MetricCard label="调度器" value={data?.scheduler_running ? "运行中" : "停止"} tone={data?.scheduler_running ? "ok" : "error"} />
|
|
||||||
<MetricCard label="扫描状态" value={data?.scan_running ? "扫描中" : "空闲"} tone={data?.scan_running ? "warning" : "default"} />
|
|
||||||
<MetricCard label="任务数量" value={data?.job_count ?? 0} />
|
|
||||||
<MetricCard label="最近错误" value={data?.recent_errors.length ?? 0} tone={(data?.recent_errors.length ?? 0) > 0 ? "error" : "default"} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_380px]">
|
|
||||||
<main className="space-y-5">
|
|
||||||
<JobGroup title="新闻与舆情" jobs={jobGroups.news} loading={loading} />
|
|
||||||
<JobGroup title="选股扫描" jobs={jobGroups.scan} loading={loading} />
|
|
||||||
<JobGroup title="跟踪复盘" jobs={jobGroups.review} loading={loading} />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<aside className="space-y-5">
|
|
||||||
<section className="glass-card-static overflow-hidden">
|
|
||||||
<div className="border-b border-border-subtle px-4 py-3">
|
|
||||||
<h2 className="text-sm font-semibold text-text-primary">最近筛选事件</h2>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border-subtle">
|
|
||||||
{(data?.recent_scan_logs || []).slice(0, 8).map((item, index) => (
|
|
||||||
<div key={`${item.scan_session}-${item.stage}-${index}`} className="px-4 py-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="truncate text-xs font-semibold text-text-primary">{stageLabel(item.stage)}</span>
|
|
||||||
<StatusPill status={item.status} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs leading-5 text-text-muted line-clamp-2">{item.summary || item.scan_session}</div>
|
|
||||||
<div className="mt-2 font-mono text-[10px] tabular-nums text-text-muted">{formatDateTime(item.created_at)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!data?.recent_scan_logs.length && <div className="p-5 text-sm text-text-muted">暂无扫描事件</div>}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="glass-card-static overflow-hidden">
|
|
||||||
<div className="border-b border-border-subtle px-4 py-3">
|
|
||||||
<h2 className="text-sm font-semibold text-text-primary">最近错误</h2>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border-subtle">
|
|
||||||
{(data?.recent_errors || []).slice(0, 6).map((item, index) => (
|
|
||||||
<div key={`${item.source}-${index}`} className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusPill status={item.level} />
|
|
||||||
<span className="truncate text-xs text-text-muted">{item.source}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs leading-5 text-text-secondary line-clamp-2">{item.message}</div>
|
|
||||||
<div className="mt-2 font-mono text-[10px] tabular-nums text-text-muted">{formatDateTime(item.created_at)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!data?.recent_errors.length && <div className="p-5 text-sm text-text-muted">暂无错误</div>}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function JobGroup({ title, jobs, loading }: { title: string; jobs: TaskCenterResult["jobs"]; loading: boolean }) {
|
|
||||||
return (
|
|
||||||
<section className="glass-card-static overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
|
||||||
<h2 className="text-sm font-semibold text-text-primary">{title}</h2>
|
|
||||||
<span className="text-[10px] text-text-muted">{jobs.length}项</span>
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-3 p-4">
|
|
||||||
{[1, 2, 3].map((item) => <div key={item} className="h-16 animate-shimmer rounded-xl bg-surface-2" />)}
|
|
||||||
</div>
|
|
||||||
) : jobs.length === 0 ? (
|
|
||||||
<div className="p-6 text-sm text-text-muted">暂无任务</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-border-subtle">
|
|
||||||
{jobs.map((job) => (
|
|
||||||
<article key={job.id} className="grid gap-3 px-4 py-4 md:grid-cols-[190px_1fr_180px] md:items-center">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-text-primary">{jobLabel(job.id)}</div>
|
|
||||||
<div className="mt-1 font-mono text-[10px] text-text-muted">{job.id}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs leading-5 text-text-muted line-clamp-2">{cleanTrigger(job.trigger)}</div>
|
|
||||||
<div className="font-mono text-[10px] tabular-nums text-text-secondary md:text-right">
|
|
||||||
{formatDateTime(job.next_run_time)}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricCard({ label, value, tone = "default" }: { label: string; value: string | number; tone?: "default" | "ok" | "warning" | "error" }) {
|
|
||||||
const color = tone === "ok" ? "text-emerald-400" : tone === "warning" ? "text-amber-400" : tone === "error" ? "text-red-400" : "text-text-primary";
|
|
||||||
return (
|
|
||||||
<div className="glass-card-static p-4">
|
|
||||||
<div className="text-[10px] text-text-muted">{label}</div>
|
|
||||||
<div className={`mt-2 truncate text-xl font-bold tabular-nums ${color}`}>{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusPill({ status }: { status: string }) {
|
|
||||||
const normalized = status.toLowerCase();
|
|
||||||
const className = normalized === "ok"
|
|
||||||
? "border-emerald-500/15 bg-emerald-500/[0.06] text-emerald-400"
|
|
||||||
: normalized === "warning" || normalized === "empty"
|
|
||||||
? "border-amber-500/15 bg-amber-500/[0.07] text-amber-400"
|
|
||||||
: "border-red-500/15 bg-red-500/[0.07] text-red-400";
|
|
||||||
const label = normalized === "ok" ? "正常" : normalized === "empty" ? "空结果" : normalized === "warning" ? "警告" : "异常";
|
|
||||||
return <span className={`w-fit rounded-md border px-2 py-0.5 text-[10px] font-medium ${className}`}>{label}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function jobLabel(id: string) {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
news_pre_market: "盘前新闻",
|
|
||||||
news_morning: "早盘新闻",
|
|
||||||
news_noon: "午间新闻",
|
|
||||||
news_afternoon: "午后新闻",
|
|
||||||
news_post_market: "盘后新闻",
|
|
||||||
pre_market: "盘前扫描",
|
|
||||||
post_market: "盘后扫描",
|
|
||||||
watchlist_analysis: "自选股分析",
|
|
||||||
strategy_iteration: "策略复盘",
|
|
||||||
};
|
|
||||||
if (map[id]) return map[id];
|
|
||||||
if (id.includes("morning")) return "早盘扫描";
|
|
||||||
if (id.includes("afternoon")) return "午后扫描";
|
|
||||||
if (id.includes("late")) return "尾盘扫描";
|
|
||||||
if (id.includes("close")) return "收盘扫描";
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stageLabel(stage: string) {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
market_temperature: "市场温度",
|
|
||||||
theme_selection: "主线主题",
|
|
||||||
strategy_profile: "策略参数",
|
|
||||||
candidate_recall: "候选召回",
|
|
||||||
realtime_quote: "实时行情",
|
|
||||||
rule_scoring: "规则评分",
|
|
||||||
final_filter: "最终作战池",
|
|
||||||
};
|
|
||||||
return map[stage] || stage;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanTrigger(trigger: string) {
|
|
||||||
return trigger.replace("cron[", "").replace("]", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(value: string) {
|
|
||||||
if (!value) return "-";
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
|
|
||||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
|
||||||
}
|
|
||||||
@ -1,9 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
|
||||||
|
|
||||||
function DashboardIcon() {
|
function DashboardIcon() {
|
||||||
return (
|
return (
|
||||||
@ -45,25 +43,6 @@ function RadarIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StrategyIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M4 19V5" />
|
|
||||||
<path d="M4 19h16" />
|
|
||||||
<path d="M7 15l3-4 3 2 4-7" />
|
|
||||||
<path d="M17 6h3v3" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DiagnoseIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChatIcon() {
|
function ChatIcon() {
|
||||||
return (
|
return (
|
||||||
@ -81,58 +60,6 @@ function WatchlistIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UsersIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
||||||
<circle cx="9" cy="7" r="4" />
|
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LogsIcon() {
|
|
||||||
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 5h16" />
|
|
||||||
<path d="M4 12h10" />
|
|
||||||
<path d="M4 19h7" />
|
|
||||||
<path d="M17 15l2 2 3-4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HealthIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M20 13c0 5-3.5 8-8 8s-8-3-8-8V5l8-3 8 3v8z" />
|
|
||||||
<path d="M9 12l2 2 4-5" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TasksIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M9 6h11" />
|
|
||||||
<path d="M9 12h11" />
|
|
||||||
<path d="M9 18h11" />
|
|
||||||
<path d="M4 6h.01" />
|
|
||||||
<path d="M4 12h.01" />
|
|
||||||
<path d="M4 18h.01" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SideNavItem({ href, icon, label, onNavigate }: { href: string; icon: React.ReactNode; label: string; onNavigate?: () => void }) {
|
function SideNavItem({ href, icon, label, onNavigate }: { href: string; icon: React.ReactNode; label: string; onNavigate?: () => void }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@ -155,8 +82,6 @@ function SideNavItem({ href, icon, label, onNavigate }: { href: string; icon: Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex-1 overflow-y-auto px-2 sm:px-3 py-4 sm:py-5 space-y-1">
|
<nav className="flex-1 overflow-y-auto px-2 sm:px-3 py-4 sm:py-5 space-y-1">
|
||||||
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" onNavigate={onNavigate} />
|
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" onNavigate={onNavigate} />
|
||||||
@ -165,15 +90,6 @@ export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
<SideNavItem href="/sentiment" icon={<RadarIcon />} label="舆情雷达" onNavigate={onNavigate} />
|
<SideNavItem href="/sentiment" icon={<RadarIcon />} label="舆情雷达" onNavigate={onNavigate} />
|
||||||
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" onNavigate={onNavigate} />
|
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" onNavigate={onNavigate} />
|
||||||
<SideNavItem href="/chat" icon={<ChatIcon />} label="研究助手" onNavigate={onNavigate} />
|
<SideNavItem href="/chat" icon={<ChatIcon />} label="研究助手" onNavigate={onNavigate} />
|
||||||
{user?.role === "admin" && (
|
|
||||||
<>
|
|
||||||
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="策略校准" onNavigate={onNavigate} />
|
|
||||||
<SideNavItem href="/ops-logs" icon={<LogsIcon />} label="系统日志" onNavigate={onNavigate} />
|
|
||||||
<SideNavItem href="/data-health" icon={<HealthIcon />} label="数据源健康" onNavigate={onNavigate} />
|
|
||||||
<SideNavItem href="/tasks" icon={<TasksIcon />} label="任务中心" onNavigate={onNavigate} />
|
|
||||||
<SideNavItem href="/settings" icon={<SettingsIcon />} label="管理设置" onNavigate={onNavigate} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user