This commit is contained in:
aaron 2026-06-01 21:29:26 +08:00
parent 1e8c38dfc5
commit 212fda0bbf
23 changed files with 270 additions and 5164 deletions

View File

@ -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(),
}

View File

@ -2,12 +2,10 @@
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.config import settings, is_trading_hours, should_prefer_realtime_today, today_trade_date
from app.core.deps import get_current_admin
from app.config import is_trading_hours, should_prefer_realtime_today, today_trade_date
router = APIRouter(prefix="/api/market", tags=["market"])
@ -48,158 +46,5 @@ async def get_temperature():
@router.get("/overview")
async def get_overview():
"""市场概况快照。
页面访问不拉腾讯/Tushare当前库里还没有指数快照表先返回空数组
后续应由扫描任务把指数概览写入本地表后再展示
"""
"""市场概况快照。"""
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

View File

@ -8,6 +8,7 @@ import traceback
from datetime import datetime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from app.engine.recommender import refresh_recommendations
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", []))
logger.info(f"扫描完成: {rec_count} 只推荐股票")
# 通过 WebSocket 推送更新
await broadcast_update({
"type": "scan_update",
"session": session_name,
@ -71,27 +71,13 @@ async def _run_watchlist_analysis():
await log_error("scheduler", f"自选股定时分析失败: {e}", detail=traceback.format_exc())
async def _run_strategy_iteration():
"""收盘后生成策略复盘,并允许小幅自动配置调整。"""
logger.info("=== 开始策略复盘与配置校准 ===")
async def _run_trigger_monitor():
"""盘中买点触发监控 — 每分钟检查埋伏池是否命中条件。"""
try:
from app.llm.strategy_iteration import build_strategy_iteration_report
report = await build_strategy_iteration_report(limit=80, include_llm=False, apply_auto_config=True)
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(),
})
from app.engine.trigger_monitor import check_triggers
await check_triggers()
except Exception as e:
logger.error(f"策略复盘自动校准失败: {e}")
from app.db.error_logger import log_error
await log_error("scheduler", f"策略复盘自动校准失败: {e}", detail=traceback.format_exc())
logger.debug(f"买点触发监控异常: {e}")
def setup_scheduler():
@ -113,13 +99,13 @@ def setup_scheduler():
replace_existing=True,
)
# 盘前准备 09:00 - 计算前一日市场温度和板块数据
# 盘前埋伏扫描 09:00
scheduler.add_job(
_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 = [
("morning_open_0935", 9, 35, "morning_open"),
("morning_open_0950", 9, 50, "morning_open"),
@ -141,7 +127,7 @@ def setup_scheduler():
replace_existing=True,
)
# 收盘总结 16:00Tushare 日线数据通常在 15:30 后更新完成)
# 收盘总结 16:00
scheduler.add_job(
_run_scan, CronTrigger(hour=16, minute=0, day_of_week="mon-fri"),
args=["post_market"], id="post_market", replace_existing=True
@ -152,9 +138,12 @@ def setup_scheduler():
id="watchlist_analysis", replace_existing=True
)
# 盘中买点触发监控9:35-15:00 每分钟执行
scheduler.add_job(
_run_strategy_iteration, CronTrigger(hour=16, minute=35, day_of_week="mon-fri"),
id="strategy_iteration", replace_existing=True
_run_trigger_monitor,
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("盘中调度器已配置完成")

View File

@ -32,7 +32,6 @@ from app.analysis.signals import generate_signals
from app.analysis.intraday import (
intraday_active_market_recall,
intraday_market_temperature,
intraday_filter_stocks,
intraday_sector_scan,
)
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)
)
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(
f"=== 今日策略: {strategy_profile.name} ({strategy_profile.strategy_id}) "
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},
)
# ── 盘中注入实时资金流(东方财富 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: 规则评分与交易计划 ──
logger.info("=== Step 3: 规则评分与交易计划 ===")
scoring_metrics: dict = {}
@ -670,21 +690,13 @@ async def _build_candidate_pool(
if intraday:
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:
logger.warning(f"盘中异动召回失败: {e}")
logger.warning(f"盘中活跃股召回失败: {e}")
intraday_candidates = []
_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:
intraday_candidates = []
realtime_candidates = []
candidates = list(merged.values())
candidates.sort(key=lambda item: (
@ -698,7 +710,7 @@ async def _build_candidate_pool(
logger.info(
f"Step 2 多路召回完成: sector={len(sector_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)}"
)
if metrics is not None:
@ -706,7 +718,6 @@ async def _build_candidate_pool(
"sector_recall": len(sector_candidates),
"trend_scan": len(trend_candidates),
"intraday_active": len(intraday_candidates),
"realtime_market": len(realtime_candidates),
}
metrics.update({
"route_counts": route_counts,
@ -949,7 +960,6 @@ async def _build_recommendations(
EntrySignal,
)
from app.analysis.signals import generate_signals
from app.analysis.capital_flow import _score_valuation
# 名称和行业映射
stock_basic = tushare_client.get_stock_basic()
@ -1017,7 +1027,7 @@ async def _build_recommendations(
supply_demand_score = score_supply_demand(df)
price_action_score = _score_price_action(df, entry_signal)
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)
sector_stage = _get_sector_stage(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
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)
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": "资金或主线承接不足"})
flow_multiplier = 1.0
theme_penalty = 1.0
if not hot_theme_match:
@ -1132,28 +1137,10 @@ async def _build_recommendations(
final_score *= theme_penalty
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")
pb = stock.get("pb")
valuation_score = _score_valuation(pe, pb)
valuation_score = 50 # 不再计算估值分,短线无意义
level = _score_to_level(final_score)
signal = "HOLD"

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

View File

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

View File

@ -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
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__)
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 = {
"stock_prefilter": "STOCK_PREFILTER_PROMPT",
"single_stock_analysis": "SINGLE_STOCK_ANALYSIS_PROMPT",
"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 ensure_default_configs() -> None:
"""兼容接口 — 启动时不再需要写策略配置到数据库。"""
pass
async def load_active_strategy_profile(profile):
row = await _load_active_strategy_row(profile.strategy_id)
if not row:
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()]
"""直接返回规则 profile,不再从数据库加载配置覆盖。"""
return profile
async def get_prompt_content(prompt_key: str, default: str) -> str:
async with get_db() as db:
result = await db.execute(
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 ""),
}
"""直接返回默认 prompt,不再从数据库加载。"""
return default

View File

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

View File

@ -1,11 +1,9 @@
"""动态策略选择器
在固定筛选引擎前增加一层先选打法再选股票的策略决策
生产筛选只使用规则和策略配置保证同一份行情输入得到稳定输出
LLM 只能用于离线复盘配置建议或解释不参与盘中策略换挡
根据市场温度和板块状态纯规则选择当日策略 profile
不再使用 LLM 或数据库配置覆盖
"""
import json
import logging
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)
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(
strategy_id="breakout_attack",
name="主线突破",
@ -122,11 +137,12 @@ async def select_strategy_profile(
market_temp: MarketTemperature | None,
hot_sectors: list[SectorInfo],
intraday: bool,
scan_session: str = "",
) -> StrategyProfile:
from app.llm.strategy_config import load_active_strategy_profile
profile = _select_rule_profile(market_temp, hot_sectors, intraday)
return await load_active_strategy_profile(profile)
"""纯规则策略选择。盘前埋伏 session 走独立 profile。"""
if scan_session == "pre_market_ambush" or scan_session == "pre_market":
return get_strategy_profile_by_id("pre_market_ambush")
return _select_rule_profile(market_temp, hot_sectors, intraday)
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("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

View File

@ -74,23 +74,28 @@ def _clean_for_json(obj):
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 = {
"trade_date": board.get("trade_date", ""),
"market_regime": board.get("market_regime", ""),
"risk_level": board.get("risk_level", ""),
"action_bias": board.get("action_bias", ""),
"position_suggestion": board.get("position_suggestion", ""),
"summary": board.get("summary", ""),
"recommended_mode": board.get("recommended_mode", ""),
"watch_sectors": board.get("watch_sectors", [])[:5],
"strategy_focus": board.get("strategy_focus", [])[:4],
"avoid_rules": board.get("avoid_rules", [])[:4],
"iteration_notes": board.get("iteration_notes", [])[:3],
"metrics": board.get("metrics", {}),
"generated_by": board.get("generated_by", "rules"),
"market_temperature": mt.temperature if mt else 0,
"up_count": mt.up_count if mt else 0,
"down_count": mt.down_count if mt else 0,
"limit_up_count": mt.limit_up_count if mt else 0,
"strategy_name": strategy.get("name", "未知"),
"market_stance": strategy.get("market_stance", ""),
"decision_note": strategy.get("decision_note", ""),
"actionable_count": len(actionable),
"watch_count": len(watch),
"total_recommendations": len(recs),
}
return json.dumps(_clean_for_json(payload), ensure_ascii=False, default=str)

View File

@ -11,7 +11,7 @@ from app.config import settings
from app.db.error_logger import PersistentErrorLogHandler, log_error
from app.db.database import init_db
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:
logging.basicConfig(
@ -103,9 +103,6 @@ async def lifespan(app: FastAPI):
await init_db()
logger.info("数据库初始化完成")
await ensure_admin_exists()
from app.llm.strategy_config import ensure_default_configs
await ensure_default_configs()
logger.info("策略配置中心初始化完成")
start_scheduler()
logger.info("调度器已启动")
yield
@ -147,7 +144,6 @@ app.include_router(stocks.router)
app.include_router(watchlists.router)
app.include_router(chat.router)
app.include_router(auth.router)
app.include_router(debug.router)
app.include_router(catalysts.router)
# WebSocket

View File

@ -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">
TushareAKShare
</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" });
}

View File

@ -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 "结合下一个交易时段的量价和板块变化再判断。";
}

View File

@ -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}` : "暂无来源";
}

View File

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

View File

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

View File

@ -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" });
}

View File

@ -1,9 +1,7 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ThemeToggle } from "@/components/theme-toggle";
function DashboardIcon() {
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() {
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 }) {
const pathname = usePathname();
@ -155,8 +82,6 @@ function SideNavItem({ href, icon, label, onNavigate }: { href: string; icon: Re
}
export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
const { user } = useAuth();
return (
<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} />
@ -165,15 +90,6 @@ export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
<SideNavItem href="/sentiment" icon={<RadarIcon />} label="舆情雷达" onNavigate={onNavigate} />
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} 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>
);
}